diff --git a/.core_files.yaml b/.core_files.yaml
index 7933556b6038..9af81c599348 100644
--- a/.core_files.yaml
+++ b/.core_files.yaml
@@ -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
diff --git a/.coveragerc b/.coveragerc
index 4827d93ed526..8494ef357bff 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -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
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index c64efda390e7..4bc1442d9e9b 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -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
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index 06a95f4cc9be..063342cc6b7f 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -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
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 6fad65734461..d0438e674dd2 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -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
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index a18c050024b8..997543f0cf1c 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -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 }}
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index b6a00492e3d4..c735a4469384 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -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"
diff --git a/.strict-typing b/.strict-typing
index a5f084116a2a..801827df6dcc 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -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.*
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 7af7a426d62b..c767647f821a 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -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?"
}
]
}
diff --git a/CODEOWNERS b/CODEOWNERS
index 684623113db6..44b7e4bce367 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/Dockerfile b/Dockerfile
index fa8f5520f22b..e229f27cb335 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 336648ae1c2b..de49bb77f127 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -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
diff --git a/build.yaml b/build.yaml
index 0bc38d722699..11b60a662952 100644
--- a/build.yaml
+++ b/build.yaml
@@ -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
diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py
index 546e4afdcfa6..c681df665571 100644
--- a/homeassistant/auth/jwt_wrapper.py
+++ b/homeassistant/auth/jwt_wrapper.py
@@ -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(
diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py
index a6c4c19b02fc..04db5fc287b7 100644
--- a/homeassistant/auth/providers/trusted_networks.py
+++ b/homeassistant/auth/providers/trusted_networks.py
@@ -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}),
)
],
)
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 2077274be55f..7e5aa853f121 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -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)
diff --git a/homeassistant/brands/airzone.json b/homeassistant/brands/airzone.json
new file mode 100644
index 000000000000..b41d1cb2e1cd
--- /dev/null
+++ b/homeassistant/brands/airzone.json
@@ -0,0 +1,5 @@
+{
+ "domain": "airzone",
+ "name": "Airzone",
+ "integrations": ["airzone", "airzone_cloud"]
+}
diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json
index 0d396ca05ed3..ce71457a656d 100644
--- a/homeassistant/brands/google.json
+++ b/homeassistant/brands/google.json
@@ -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"
]
}
diff --git a/homeassistant/brands/yale.json b/homeassistant/brands/yale.json
index 87c119fdd40f..53dc9b435697 100644
--- a/homeassistant/brands/yale.json
+++ b/homeassistant/brands/yale.json
@@ -1,5 +1,5 @@
{
"domain": "yale",
"name": "Yale",
- "integrations": ["august", "yale_smart_alarm", "yalexs_ble"]
+ "integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"]
}
diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py
index c7943d15bd0e..2a19f0d0291a 100644
--- a/homeassistant/components/accuweather/__init__.py
+++ b/homeassistant/components/accuweather/__init__.py
@@ -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,
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index fbf31720e13e..658b5d368d05 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"quality_scale": "platinum",
- "requirements": ["accuweather==0.5.2"]
+ "requirements": ["accuweather==1.0.0"]
}
diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py
index 5d0c70de4e15..5a85b4a4c387 100644
--- a/homeassistant/components/accuweather/sensor.py
+++ b/homeassistant/components/accuweather/sensor.py
@@ -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",
diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py
index 0ef729b9b692..6107285e3760 100644
--- a/homeassistant/components/accuweather/weather.py
+++ b/homeassistant/components/accuweather/weather.py
@@ -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)
diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py
index 1c64206891c2..5242540748f5 100644
--- a/homeassistant/components/aemet/weather_update_coordinator.py
+++ b/homeassistant/components/aemet/weather_update_coordinator.py
@@ -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]
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index 513c589dc774..5212ff51fe88 100644
--- a/homeassistant/components/airthings/sensor.py
+++ b/homeassistant/components/airthings/sensor.py
@@ -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
diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py
index e18d76128f23..b6c8c25491b6 100644
--- a/homeassistant/components/airthings_ble/sensor.py
+++ b/homeassistant/components/airthings_ble/sensor.py
@@ -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__(
diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py
index 1a167f49f78e..3c47c333b92a 100644
--- a/homeassistant/components/airzone/climate.py
+++ b/homeassistant/components/airzone/climate.py
@@ -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
+ )
diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py
index a05b8cd6181a..9ee923ba1af9 100644
--- a/homeassistant/components/airzone/entity.py
+++ b/homeassistant/components/airzone/entity.py
@@ -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
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 991584dd8f8a..637066629dbb 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -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"]
}
diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py
index 023015121d72..1a0d577bb35f 100644
--- a/homeassistant/components/airzone/select.py
+++ b/homeassistant/components/airzone/select.py
@@ -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,
diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json
index 306e63da36c0..037ebe52d782 100644
--- a/homeassistant/components/airzone/strings.json
+++ b/homeassistant/components/airzone/strings.json
@@ -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"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py
new file mode 100644
index 000000000000..cdc0f30a5337
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/__init__.py
@@ -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
diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py
new file mode 100644
index 000000000000..32274d4e8efc
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/config_flow.py
@@ -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,
+ )
diff --git a/homeassistant/components/airzone_cloud/const.py b/homeassistant/components/airzone_cloud/const.py
new file mode 100644
index 000000000000..625d897188de
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/const.py
@@ -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
diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py
new file mode 100644
index 000000000000..edd993550920
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/coordinator.py
@@ -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()
diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py
new file mode 100644
index 000000000000..a86f95d6187b
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/diagnostics.py
@@ -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),
+ }
diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py
new file mode 100644
index 000000000000..c7e59ee1a3f6
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/entity.py
@@ -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
diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json
new file mode 100644
index 000000000000..b2899a7c80ce
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/manifest.json
@@ -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"]
+}
diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py
new file mode 100644
index 000000000000..ee162ef5fecd
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/sensor.py
@@ -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()
diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json
new file mode 100644
index 000000000000..12f155b44868
--- /dev/null
+++ b/homeassistant/components/airzone_cloud/strings.json
@@ -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%]"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py
index 2cf526e5626b..32eb34333c9b 100644
--- a/homeassistant/components/aladdin_connect/cover.py
+++ b/homeassistant/components/aladdin_connect/cover.py
@@ -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."""
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 6b01cab2becc..4025bbd4cc4e 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -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%]"
+ }
}
}
diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py
index 2dbda64568f0..86c038e2da87 100644
--- a/homeassistant/components/alexa/auth.py
+++ b/homeassistant/components/alexa/auth.py
@@ -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()
diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py
index e086d525cf12..d47a548979e9 100644
--- a/homeassistant/components/alexa/config.py
+++ b/homeassistant/components/alexa/config.py
@@ -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.
diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py
index 4c8722347464..06f76b8806e0 100644
--- a/homeassistant/components/alexa/intent.py
+++ b/homeassistant/components/alexa/intent.py
@@ -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."""
diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py
index a189c364c02d..ebab3bcee8ce 100644
--- a/homeassistant/components/alexa/state_report.py
+++ b/homeassistant/components/alexa/state_report.py
@@ -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
diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py
index 97e0af7f18ed..5db46fc019e0 100644
--- a/homeassistant/components/amazon_polly/tts.py
+++ b/homeassistant/components/amazon_polly/tts.py
@@ -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"):
diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py
index c02c1a3a3b6e..ee36aa78e63f 100644
--- a/homeassistant/components/analytics/__init__.py
+++ b/homeassistant/components/analytics/__init__.py
@@ -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."""
diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py
index fb275342cb0c..bdcf08bb2f62 100644
--- a/homeassistant/components/androidtv_remote/__init__.py
+++ b/homeassistant/components/androidtv_remote/__init__.py
@@ -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:
diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py
index 24b64c622a99..f7e1078d3fa6 100644
--- a/homeassistant/components/androidtv_remote/config_flow.py
+++ b/homeassistant/components/androidtv_remote/config_flow.py
@@ -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}
diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py
new file mode 100644
index 000000000000..862f317ee82d
--- /dev/null
+++ b/homeassistant/components/androidtv_remote/entity.py
@@ -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
diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json
index 0e5d896a11f9..c728ea0a6827 100644
--- a/homeassistant/components/androidtv_remote/manifest.json
+++ b/homeassistant/components/androidtv_remote/manifest.json
@@ -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."]
}
diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py
new file mode 100644
index 000000000000..eccfc8ce25b6
--- /dev/null
+++ b/homeassistant/components/androidtv_remote/media_player.py
@@ -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
diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py
index 1c68c92bc684..f4c2ae51ce1e 100644
--- a/homeassistant/components/androidtv_remote/remote.py
+++ b/homeassistant/components/androidtv_remote/remote.py
@@ -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
diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py
index 7810e00ded0b..2fee8a6beeb4 100644
--- a/homeassistant/components/anova/__init__.py
+++ b/homeassistant/components/anova/__init__.py
@@ -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
diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py
index cd4eab9c2e52..2e5505a9fdd2 100644
--- a/homeassistant/components/anova/coordinator.py
+++ b/homeassistant/components/anova/coordinator.py
@@ -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()
diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json
index d307a9314f95..7c4509e2f256 100644
--- a/homeassistant/components/anova/manifest.json
+++ b/homeassistant/components/anova/manifest.json
@@ -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"]
}
diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py
index a5ea3ee2fd86..6336aa61e1c2 100644
--- a/homeassistant/components/anova/sensor.py
+++ b/homeassistant/components/anova/sensor.py
@@ -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)
diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json
index 19d0e52b7d25..b14246a392d7 100644
--- a/homeassistant/components/anova/strings.json
+++ b/homeassistant/components/anova/strings.json
@@ -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": {
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index 4e0e46f6392f..17168700f662 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -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
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index 5c0a60ecef7f..6538bd345de4 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -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."""
diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py
index 9b80d992cdd9..8a2130faca07 100644
--- a/homeassistant/components/apple_tv/config_flow.py
+++ b/homeassistant/components/apple_tv/config_flow.py
@@ -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
diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json
index c534c635317c..2d2df955441a 100644
--- a/homeassistant/components/apple_tv/manifest.json
+++ b/homeassistant/components/apple_tv/manifest.json
@@ -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.",
diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py
index 06618e4f2a35..4196dd1bd9a9 100644
--- a/homeassistant/components/apple_tv/media_player.py
+++ b/homeassistant/components/apple_tv/media_player.py
@@ -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."""
diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py
index f1471f296665..679ff9bfac45 100644
--- a/homeassistant/components/application_credentials/__init__.py
+++ b/homeassistant/components/application_credentials/__init__.py
@@ -57,6 +57,8 @@ CREATE_FIELDS = {
}
UPDATE_FIELDS: dict = {} # Not supported
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@dataclass
class ClientCredential:
diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json
index 453fc7735144..9a56f5d91ebb 100644
--- a/homeassistant/components/apprise/manifest.json
+++ b/homeassistant/components/apprise/manifest.json
@@ -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"]
}
diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py
index 6efa24c5a0f5..1c67723fc028 100644
--- a/homeassistant/components/arest/switch.py
+++ b/homeassistant/components/arest/switch.py
@@ -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
diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py
index f03734e63dff..d468a93eca05 100644
--- a/homeassistant/components/arwn/sensor.py
+++ b/homeassistant/components/arwn/sensor.py
@@ -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.
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index 7af379804e1d..9e460464cb9a 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -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()
diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py
index fa26d916eeb7..c5ffdcaf2d34 100644
--- a/homeassistant/components/assist_pipeline/error.py
+++ b/homeassistant/components/assist_pipeline/error.py
@@ -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."""
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index d347e433f463..12764c04f046 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -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)
diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py
index 6c1dbe3dbce9..3d8a07dc0b3b 100644
--- a/homeassistant/components/assist_pipeline/websocket_api.py
+++ b/homeassistant/components/assist_pipeline/websocket_api.py
@@ -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(
diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py
index 8be7d8dd2d19..8738b58dab9d 100644
--- a/homeassistant/components/august/__init__.py
+++ b/homeassistant/components/august/__init__.py
@@ -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)
diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py
index 58f1c2fc9764..670d16084210 100644
--- a/homeassistant/components/august/config_flow.py
+++ b/homeassistant/components/august/config_flow.py
@@ -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]
)
diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py
index 9dcf96f057af..badff721d109 100644
--- a/homeassistant/components/august/gateway.py
+++ b/homeassistant/components/august/gateway.py
@@ -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
diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py
index 6e0969a6724c..169a344e2bd0 100644
--- a/homeassistant/components/august/sensor.py
+++ b/homeassistant/components/august/sensor.py
@@ -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."""
diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py
index 305a42d4dccf..b5dc236dfa24 100644
--- a/homeassistant/components/aurora_abb_powerone/__init__.py
+++ b/homeassistant/components/aurora_abb_powerone/__init__.py
@@ -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
diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py
index 1b47fb093936..deaf3b7892d1 100644
--- a/homeassistant/components/auth/__init__.py
+++ b/homeassistant/components/auth/__init__.py
@@ -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(
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 4712592edc7f..600cc6013e49 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -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()
diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py
index 1a65b31ef728..f53e69fba9fc 100644
--- a/homeassistant/components/axis/device.py
+++ b/homeassistant/components/axis/device.py
@@ -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."""
diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json
index b8cd005a2644..296a3da8b663 100644
--- a/homeassistant/components/axis/manifest.json
+++ b/homeassistant/components/axis/manifest.json
@@ -26,7 +26,7 @@
"iot_class": "local_push",
"loggers": ["axis"],
"quality_scale": "platinum",
- "requirements": ["axis==47"],
+ "requirements": ["axis==48"],
"ssdp": [
{
"manufacturer": "AXIS"
diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py
index 30417a7f94a1..4e69bea1a38d 100644
--- a/homeassistant/components/azure_event_hub/__init__.py
+++ b/homeassistant/components/azure_event_hub/__init__.py
@@ -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
diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json
index 1ec3c690c4ed..059f6300aec1 100644
--- a/homeassistant/components/azure_service_bus/manifest.json
+++ b/homeassistant/components/azure_service_bus/manifest.json
@@ -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"]
}
diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py
index 8d5f9764959c..8ce8bee77936 100644
--- a/homeassistant/components/backup/__init__.py
+++ b/homeassistant/components/backup/__init__.py
@@ -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."""
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index 1f8b70f4d357..fe0d494a6509 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -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 = {
diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json
index b5b5b76967e6..37fd5cee7c66 100644
--- a/homeassistant/components/baf/manifest.json
+++ b/homeassistant/components/baf/manifest.json
@@ -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.",
diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py
index 91fe110d388a..020f34fefafb 100644
--- a/homeassistant/components/baf/number.py
+++ b/homeassistant/components/baf/number.py
@@ -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:
diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py
index 72694248fa1c..05e2956c10ec 100644
--- a/homeassistant/components/baidu/tts.py
+++ b/homeassistant/components/baidu/tts.py
@@ -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(
diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py
index 969a52d15149..b2fd371a2604 100644
--- a/homeassistant/components/binary_sensor/device_trigger.py
+++ b/homeassistant/components/binary_sensor/device_trigger.py
@@ -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(
{
diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json
index f2bbc72e7a5f..ca349e193282 100644
--- a/homeassistant/components/binary_sensor/strings.json
+++ b/homeassistant/components/binary_sensor/strings.json
@@ -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%]"
+ }
}
}
diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json
index 3e061df32a29..302a9f1e86a0 100644
--- a/homeassistant/components/blink/manifest.json
+++ b/homeassistant/components/blink/manifest.json
@@ -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"]
}
diff --git a/homeassistant/components/blueprint/__init__.py b/homeassistant/components/blueprint/__init__.py
index 3087309f36ae..1fe1ad8e189f 100644
--- a/homeassistant/components/blueprint/__init__.py
+++ b/homeassistant/components/blueprint/__init__.py
@@ -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."""
diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py
index 2c48b473b73d..8d3bc0ae5e2f 100644
--- a/homeassistant/components/bluetooth/__init__.py
+++ b/homeassistant/components/bluetooth/__init__.py
@@ -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
diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py
index 75809f42e6cb..3210822e7952 100644
--- a/homeassistant/components/bluetooth/manager.py
+++ b/homeassistant/components/bluetooth/manager.py
@@ -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),
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index 4b957674655b..8d936b7286ff 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -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"
]
}
diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json
index a988477778d9..cae88ef24c19 100644
--- a/homeassistant/components/bluetooth/strings.json
+++ b/homeassistant/components/bluetooth/strings.json
@@ -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": {
diff --git a/homeassistant/components/bluetooth_adapters/__init__.py b/homeassistant/components/bluetooth_adapters/__init__.py
index c2af10d5455b..3d5580aabf17 100644
--- a/homeassistant/components/bluetooth_adapters/__init__.py
+++ b/homeassistant/components/bluetooth_adapters/__init__.py
@@ -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.
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index 8d5d842e915c..27f2d99cd2d7 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -44,6 +44,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
+ Platform.SWITCH,
]
SERVICE_UPDATE_STATE = "update_state"
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index df25efb6d5e5..640f4e3653be 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -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,
+ ),
)
diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py
index 873a72762ab4..5285820b32dd 100644
--- a/homeassistant/components/bmw_connected_drive/button.py
+++ b/homeassistant/components/bmw_connected_drive/button.py
@@ -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()
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
index 98d312a9836e..926706397a68 100644
--- a/homeassistant/components/bmw_connected_drive/config_flow.py
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -6,7 +6,8 @@ from typing import Any
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name
-from httpx import HTTPError
+from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
+from httpx import RequestError
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
@@ -41,7 +42,9 @@ async def validate_input(
try:
await auth.login()
- except HTTPError as ex:
+ except MyBMWAuthError as ex:
+ raise InvalidAuth from ex
+ except (MyBMWAPIError, RequestError) as ex:
raise CannotConnect from ex
# Return info that you want to store in the config entry.
@@ -83,6 +86,8 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
except CannotConnect:
errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
if info:
if self._reauth_entry:
@@ -163,3 +168,7 @@ class BMWOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py
index f31198017dc2..f6354422312d 100644
--- a/homeassistant/components/bmw_connected_drive/coordinator.py
+++ b/homeassistant/components/bmw_connected_drive/coordinator.py
@@ -6,8 +6,8 @@ import logging
from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name
-from bimmer_connected.models import GPSPosition
-from httpx import HTTPError, HTTPStatusError, TimeoutException
+from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
+from httpx import RequestError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
@@ -59,20 +59,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
try:
await self.account.get_vehicles()
- except (HTTPError, HTTPStatusError, TimeoutException) as err:
- if isinstance(err, HTTPStatusError) and err.response.status_code == 429:
- # Increase scan interval to not jump to not bring up the issue next time
- self.update_interval = timedelta(
- seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3
- )
- if isinstance(err, HTTPStatusError) and err.response.status_code in (
- 401,
- 403,
- ):
- # Clear refresh token only and trigger reauth
- self._update_config_entry_refresh_token(None)
- raise ConfigEntryAuthFailed(str(err)) from err
- raise UpdateFailed(f"Error communicating with BMW API: {err}") from err
+ except MyBMWAuthError as err:
+ # Clear refresh token and trigger reauth
+ self._update_config_entry_refresh_token(None)
+ raise ConfigEntryAuthFailed(err) from err
+ except (MyBMWAPIError, RequestError) as err:
+ raise UpdateFailed(err) from err
if self.account.refresh_token != old_refresh_token:
self._update_config_entry_refresh_token(self.account.refresh_token)
@@ -82,9 +74,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
self.account.refresh_token,
)
- # Reset scan interval after successful update
- self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS)
-
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
"""Update or delete the refresh_token in the Config Entry."""
data = {
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index ffc6cf6d8b73..d20ccd1fbb4b 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -68,6 +68,8 @@ class BMWLock(BMWBaseEntity, LockEntity):
self.async_write_ha_state()
await self.vehicle.remote_services.trigger_remote_door_lock()
+ self.coordinator.async_update_listeners()
+
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the car."""
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
@@ -79,6 +81,8 @@ class BMWLock(BMWBaseEntity, LockEntity):
self.async_write_ha_state()
await self.vehicle.remote_services.trigger_remote_door_unlock()
+ self.coordinator.async_update_listeners()
+
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index c600a1529a9c..c9612d00c643 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
- "requirements": ["bimmer_connected==0.13.5"]
+ "requirements": ["bimmer_connected==0.13.6"]
}
diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py
index f26a2027f72c..c8f72b272c16 100644
--- a/homeassistant/components/bmw_connected_drive/number.py
+++ b/homeassistant/components/bmw_connected_drive/number.py
@@ -40,7 +40,6 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin):
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
- mode: NumberMode = NumberMode.AUTO
NUMBER_TYPES: list[BMWNumberEntityDescription] = [
@@ -99,7 +98,6 @@ class BMWNumber(BMWBaseEntity, NumberEntity):
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
- self._attr_mode = description.mode
@property
def native_value(self) -> float | None:
@@ -118,3 +116,5 @@ class BMWNumber(BMWBaseEntity, NumberEntity):
await self.entity_description.remote_service(self.vehicle, value)
except MyBMWAPIError as ex:
raise HomeAssistantError(ex) from ex
+
+ self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py
index 52d35b477a2d..0b20ed908732 100644
--- a/homeassistant/components/bmw_connected_drive/select.py
+++ b/homeassistant/components/bmw_connected_drive/select.py
@@ -124,3 +124,5 @@ class BMWSelect(BMWBaseEntity, SelectEntity):
option,
)
await self.entity_description.remote_service(self.vehicle, option)
+
+ self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index c797de99859a..314ff47c14cf 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import LENGTH, PERCENTAGE, VOLUME
+from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -53,6 +53,14 @@ def convert_and_round(
SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
# --- Generic ---
+ "ac_current_limit": BMWSensorEntityDescription(
+ key="ac_current_limit",
+ name="AC current limit",
+ key_class="charging_profile",
+ unit_type=UnitOfElectricCurrent.AMPERE,
+ icon="mdi:current-ac",
+ entity_registry_enabled_default=False,
+ ),
"charging_start_time": BMWSensorEntityDescription(
key="charging_start_time",
name="Charging start time",
@@ -73,6 +81,13 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
icon="mdi:ev-station",
value=lambda x, y: x.value,
),
+ "charging_target": BMWSensorEntityDescription(
+ key="charging_target",
+ name="Charging target",
+ key_class="fuel_and_battery",
+ icon="mdi:battery-charging-high",
+ unit_type=PERCENTAGE,
+ ),
"remaining_battery_percent": BMWSensorEntityDescription(
key="remaining_battery_percent",
name="Remaining battery percent",
diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py
new file mode 100644
index 000000000000..41243ca93239
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/switch.py
@@ -0,0 +1,132 @@
+"""Switch platform for BMW."""
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from bimmer_connected.models import MyBMWAPIError
+from bimmer_connected.vehicle import MyBMWVehicle
+from bimmer_connected.vehicle.fuel_and_battery import ChargingState
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import BMWBaseEntity
+from .const import DOMAIN
+from .coordinator import BMWDataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class BMWRequiredKeysMixin:
+ """Mixin for required keys."""
+
+ value_fn: Callable[[MyBMWVehicle], bool]
+ remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
+ remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
+
+
+@dataclass
+class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin):
+ """Describes BMW switch entity."""
+
+ is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
+ dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
+
+
+CHARGING_STATE_ON = {
+ ChargingState.CHARGING,
+ ChargingState.COMPLETE,
+ ChargingState.FULLY_CHARGED,
+ ChargingState.FINISHED_FULLY_CHARGED,
+ ChargingState.FINISHED_NOT_FULL,
+ ChargingState.TARGET_REACHED,
+}
+
+NUMBER_TYPES: list[BMWSwitchEntityDescription] = [
+ BMWSwitchEntityDescription(
+ key="climate",
+ name="Climate",
+ is_available=lambda v: v.is_remote_climate_stop_enabled,
+ value_fn=lambda v: v.climate.is_climate_on,
+ remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(),
+ remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(),
+ icon="mdi:fan",
+ ),
+ BMWSwitchEntityDescription(
+ key="charging",
+ name="Charging",
+ is_available=lambda v: v.is_remote_charge_stop_enabled,
+ value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON,
+ remote_service_on=lambda v: v.remote_services.trigger_charge_start(),
+ remote_service_off=lambda v: v.remote_services.trigger_charge_stop(),
+ icon="mdi:ev-station",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the MyBMW switch from config entry."""
+ coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ entities: list[BMWSwitch] = []
+
+ for vehicle in coordinator.account.vehicles:
+ if not coordinator.read_only:
+ entities.extend(
+ [
+ BMWSwitch(coordinator, vehicle, description)
+ for description in NUMBER_TYPES
+ if description.is_available(vehicle)
+ ]
+ )
+ async_add_entities(entities)
+
+
+class BMWSwitch(BMWBaseEntity, SwitchEntity):
+ """Representation of BMW Switch entity."""
+
+ entity_description: BMWSwitchEntityDescription
+
+ def __init__(
+ self,
+ coordinator: BMWDataUpdateCoordinator,
+ vehicle: MyBMWVehicle,
+ description: BMWSwitchEntityDescription,
+ ) -> None:
+ """Initialize an BMW Switch."""
+ super().__init__(coordinator, vehicle)
+ self.entity_description = description
+ self._attr_unique_id = f"{vehicle.vin}-{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return the entity value to represent the entity state."""
+ return self.entity_description.value_fn(self.vehicle)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ try:
+ await self.entity_description.remote_service_on(self.vehicle)
+ except MyBMWAPIError as ex:
+ raise HomeAssistantError(ex) from ex
+
+ self.coordinator.async_update_listeners()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ try:
+ await self.entity_description.remote_service_off(self.vehicle)
+ except MyBMWAPIError as ex:
+ raise HomeAssistantError(ex) from ex
+
+ self.coordinator.async_update_listeners()
diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py
index 559aae25abff..3b1312a64c5c 100644
--- a/homeassistant/components/broadlink/__init__.py
+++ b/homeassistant/components/broadlink/__init__.py
@@ -31,12 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Broadlink device from a config entry."""
data: BroadlinkData = hass.data[DOMAIN]
+ device = BroadlinkDevice(hass, entry)
+ if not await device.async_setup():
+ return False
if data.heartbeat is None:
data.heartbeat = BroadlinkHeartbeat(hass)
hass.async_create_task(data.heartbeat.async_setup())
-
- device = BroadlinkDevice(hass, entry)
- return await device.async_setup()
+ return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json
index cb4e145d11e9..5778520e530a 100644
--- a/homeassistant/components/broadlink/manifest.json
+++ b/homeassistant/components/broadlink/manifest.json
@@ -21,6 +21,15 @@
},
{
"macaddress": "C8F742*"
+ },
+ {
+ "macaddress": "E81656*"
+ },
+ {
+ "macaddress": "E87072*"
+ },
+ {
+ "macaddress": "EC0BAE*"
}
],
"documentation": "https://www.home-assistant.io/integrations/broadlink",
diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py
index 4bbb3fe15136..c116a1bb6357 100644
--- a/homeassistant/components/broadlink/remote.py
+++ b/homeassistant/components/broadlink/remote.py
@@ -39,7 +39,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .entity import BroadlinkEntity
@@ -330,8 +330,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity):
)
try:
- start_time = dt.utcnow()
- while (dt.utcnow() - start_time) < LEARNING_TIMEOUT:
+ start_time = dt_util.utcnow()
+ while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
await asyncio.sleep(1)
try:
code = await device.async_request(device.api.check_data)
@@ -368,8 +368,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity):
)
try:
- start_time = dt.utcnow()
- while (dt.utcnow() - start_time) < LEARNING_TIMEOUT:
+ start_time = dt_util.utcnow()
+ while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
await asyncio.sleep(1)
found = await device.async_request(device.api.check_frequency)
if found:
@@ -403,8 +403,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity):
)
try:
- start_time = dt.utcnow()
- while (dt.utcnow() - start_time) < LEARNING_TIMEOUT:
+ start_time = dt_util.utcnow()
+ while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
await asyncio.sleep(1)
try:
code = await device.async_request(device.api.check_data)
diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py
index f3837c732633..da8461bf90fc 100644
--- a/homeassistant/components/broadlink/updater.py
+++ b/homeassistant/components/broadlink/updater.py
@@ -6,7 +6,7 @@ import logging
from broadlink.exceptions import AuthorizationError, BroadlinkException
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ class BroadlinkUpdateManager(ABC):
except (BroadlinkException, OSError) as err:
if self.available and (
- dt.utcnow() - self.last_update > self.SCAN_INTERVAL * 3
+ dt_util.utcnow() - self.last_update > self.SCAN_INTERVAL * 3
or isinstance(err, (AuthorizationError, OSError))
):
self.available = False
@@ -84,7 +84,7 @@ class BroadlinkUpdateManager(ABC):
self.device.api.host[0],
)
self.available = True
- self.last_update = dt.utcnow()
+ self.last_update = dt_util.utcnow()
return data
@abstractmethod
diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py
index 954621ed66ff..b01f04fa1401 100644
--- a/homeassistant/components/browser/__init__.py
+++ b/homeassistant/components/browser/__init__.py
@@ -4,6 +4,7 @@ import webbrowser
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
ATTR_URL = "url"
@@ -20,6 +21,8 @@ SERVICE_BROWSE_URL_SCHEMA = vol.Schema(
}
)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
def _browser_url(service: ServiceCall) -> None:
"""Browse to URL."""
diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py
index 1255def44cb2..3e2e17a9a211 100644
--- a/homeassistant/components/bthome/__init__.py
+++ b/homeassistant/components/bthome/__init__.py
@@ -11,9 +11,6 @@ from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
-from homeassistant.components.bluetooth.passive_update_processor import (
- PassiveBluetoothProcessorCoordinator,
-)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -26,7 +23,7 @@ from .const import (
DOMAIN,
BTHomeBleEvent,
)
-from .models import BTHomeData
+from .coordinator import BTHomePassiveBluetoothProcessorCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -42,7 +39,10 @@ def process_service_info(
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
update = data.update(service_info)
- domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id]
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
+ entry.entry_id
+ ]
+ discovered_device_classes = coordinator.discovered_device_classes
if update.events:
address = service_info.device.address
for device_key, event in update.events.items():
@@ -59,16 +59,12 @@ def process_service_info(
event_class = event.device_key.key
event_type = event.event_type
- if event_class not in domain_data.discovered_event_classes:
- domain_data.discovered_event_classes.add(event_class)
+ if event_class not in discovered_device_classes:
+ discovered_device_classes.add(event_class)
hass.config_entries.async_update_entry(
entry,
data=entry.data
- | {
- CONF_DISCOVERED_EVENT_CLASSES: list(
- domain_data.discovered_event_classes
- )
- },
+ | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)},
)
hass.bus.async_fire(
@@ -104,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_registry = async_get(hass)
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
- ] = PassiveBluetoothProcessorCoordinator(
+ ] = BTHomePassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
@@ -112,11 +108,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_method=lambda service_info: process_service_info(
hass, entry, data, service_info, device_registry
),
+ device_data=data,
+ discovered_device_classes=set(
+ entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])
+ ),
connectable=False,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- domain_data = BTHomeData(set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])))
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = domain_data
entry.async_on_unload(
coordinator.async_start()
diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py
index ad36fe3644ed..d9d24e950071 100644
--- a/homeassistant/components/bthome/binary_sensor.py
+++ b/homeassistant/components/bthome/binary_sensor.py
@@ -13,9 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.components.bluetooth.passive_update_processor import (
- PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
- PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
@@ -23,6 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
+from .coordinator import (
+ BTHomePassiveBluetoothDataProcessor,
+ BTHomePassiveBluetoothProcessorCoordinator,
+)
from .device import device_key_to_bluetooth_entity_key
BINARY_SENSOR_DESCRIPTIONS = {
@@ -173,10 +175,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BTHome BLE binary sensors."""
- coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
- processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
+ processor = BTHomePassiveBluetoothDataProcessor(
+ sensor_update_to_bluetooth_data_update
+ )
entry.async_on_unload(
processor.async_add_entities_listener(
BTHomeBluetoothBinarySensorEntity, async_add_entities
@@ -186,7 +190,7 @@ async def async_setup_entry(
class BTHomeBluetoothBinarySensorEntity(
- PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]],
+ PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor],
BinarySensorEntity,
):
"""Representation of a BTHome binary sensor."""
@@ -195,3 +199,11 @@ class BTHomeBluetoothBinarySensorEntity(
def is_on(self) -> bool | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator = (
+ self.processor.coordinator
+ )
+ return coordinator.device_data.sleepy_device or super().available
diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py
new file mode 100644
index 000000000000..dafa932a73e9
--- /dev/null
+++ b/homeassistant/components/bthome/coordinator.py
@@ -0,0 +1,42 @@
+"""The BTHome Bluetooth integration."""
+from collections.abc import Callable
+from logging import Logger
+from typing import Any
+
+from bthome_ble import BTHomeBluetoothDeviceData
+
+from homeassistant.components.bluetooth import (
+ BluetoothScanningMode,
+ BluetoothServiceInfoBleak,
+)
+from homeassistant.components.bluetooth.passive_update_processor import (
+ PassiveBluetoothDataProcessor,
+ PassiveBluetoothProcessorCoordinator,
+)
+from homeassistant.core import HomeAssistant
+
+
+class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator):
+ """Define a BTHome Bluetooth Passive Update Processor Coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ logger: Logger,
+ address: str,
+ mode: BluetoothScanningMode,
+ update_method: Callable[[BluetoothServiceInfoBleak], Any],
+ device_data: BTHomeBluetoothDeviceData,
+ discovered_device_classes: set[str],
+ connectable: bool = False,
+ ) -> None:
+ """Initialize the BTHome Bluetooth Passive Update Processor Coordinator."""
+ super().__init__(hass, logger, address, mode, update_method, connectable)
+ self.discovered_device_classes = discovered_device_classes
+ self.device_data = device_data
+
+
+class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor):
+ """Define a BTHome Bluetooth Passive Update Data Processor."""
+
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator
diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json
index 87a84e5fab00..ef3d9bc002d1 100644
--- a/homeassistant/components/bthome/manifest.json
+++ b/homeassistant/components/bthome/manifest.json
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
- "requirements": ["bthome-ble==2.9.0"]
+ "requirements": ["bthome-ble==2.11.3"]
}
diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py
deleted file mode 100644
index 558f19c77423..000000000000
--- a/homeassistant/components/bthome/models.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""The bthome integration models."""
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-
-@dataclass
-class BTHomeData:
- """Data for the bthome integration."""
-
- discovered_event_classes: set[str]
diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py
index 9b5def30054c..f8693c5fb348 100644
--- a/homeassistant/components/bthome/sensor.py
+++ b/homeassistant/components/bthome/sensor.py
@@ -5,9 +5,7 @@ from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdat
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
- PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
- PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
@@ -42,6 +40,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
+from .coordinator import (
+ BTHomePassiveBluetoothDataProcessor,
+ BTHomePassiveBluetoothProcessorCoordinator,
+)
from .device import device_key_to_bluetooth_entity_key
SENSOR_DESCRIPTIONS = {
@@ -343,10 +345,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the BTHome BLE sensors."""
- coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
- processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
+ processor = BTHomePassiveBluetoothDataProcessor(
+ sensor_update_to_bluetooth_data_update
+ )
entry.async_on_unload(
processor.async_add_entities_listener(
BTHomeBluetoothSensorEntity, async_add_entities
@@ -356,7 +360,7 @@ async def async_setup_entry(
class BTHomeBluetoothSensorEntity(
- PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]],
+ PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor],
SensorEntity,
):
"""Representation of a BTHome BLE sensor."""
@@ -365,3 +369,11 @@ class BTHomeBluetoothSensorEntity(
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ coordinator: BTHomePassiveBluetoothProcessorCoordinator = (
+ self.processor.coordinator
+ )
+ return coordinator.device_data.sleepy_device or super().available
diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py
index 6af579dd74f6..8111f63c923f 100644
--- a/homeassistant/components/buienradar/const.py
+++ b/homeassistant/components/buienradar/const.py
@@ -18,3 +18,49 @@ DEFAULT_COUNTRY = "NL"
SCHEDULE_OK = 10
"""When an error occurred, new call after (minutes)."""
SCHEDULE_NOK = 2
+
+STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"]
+
+STATE_DETAILED_CONDITIONS = [
+ "clear",
+ "partlycloudy",
+ "partlycloudy-fog",
+ "partlycloudy-light-rain",
+ "partlycloudy-rain",
+ "cloudy",
+ "fog",
+ "rainy",
+ "light-rain",
+ "light-snow",
+ "partlycloudy-light-snow",
+ "partlycloudy-snow",
+ "partlycloudy-lightning",
+ "snowy",
+ "snowy-rainy",
+ "lightning",
+]
+
+STATE_CONDITION_CODES = [
+ "a",
+ "b",
+ "j",
+ "o",
+ "r",
+ "c",
+ "p",
+ "d",
+ "n",
+ "f",
+ "h",
+ "k",
+ "l",
+ "q",
+ "w",
+ "m",
+ "u",
+ "i",
+ "v",
+ "t",
+ "g",
+ "s",
+]
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index 06b97cdedadb..b5c6e9cf32c6 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -48,7 +48,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
-from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN
+from .const import (
+ CONF_TIMEFRAME,
+ DEFAULT_TIMEFRAME,
+ DOMAIN,
+ STATE_CONDITION_CODES,
+ STATE_CONDITIONS,
+ STATE_DETAILED_CONDITIONS,
+)
from .util import BrData
_LOGGER = logging.getLogger(__name__)
@@ -67,584 +74,620 @@ STATIONNAME_LABEL = "Stationname"
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="stationname",
- name=STATIONNAME_LABEL,
+ translation_key="stationname",
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="barometerfc",
- name="Barometer value",
+ translation_key="barometerfc",
icon="mdi:gauge",
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="barometerfcname",
- name="Barometer",
+ translation_key="barometerfcname",
icon="mdi:gauge",
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="barometerfcnamenl",
- name="Barometer",
+ translation_key="barometerfcnamenl",
icon="mdi:gauge",
),
SensorEntityDescription(
key="condition",
- name="Condition",
+ translation_key="condition",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="conditioncode",
- name="Condition code",
+ translation_key="conditioncode",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditiondetailed",
- name="Detailed condition",
+ translation_key="conditiondetailed",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditionexact",
- name="Full condition",
+ translation_key="conditionexact",
),
SensorEntityDescription(
key="symbol",
- name="Symbol",
+ translation_key="symbol",
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="feeltemperature",
- name="Feel temperature",
+ translation_key="feeltemperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="humidity",
- name="Humidity",
+ translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:water-percent",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="temperature",
- name="Temperature",
+ translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="groundtemperature",
- name="Ground temperature",
+ translation_key="groundtemperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="windspeed",
- name="Wind speed",
+ translation_key="windspeed",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="windforce",
- name="Wind force",
+ translation_key="windforce",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="winddirection",
- name="Wind direction",
+ translation_key="winddirection",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth",
- name="Wind direction azimuth",
+ translation_key="windazimuth",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="pressure",
- name="Pressure",
+ translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.HPA,
icon="mdi:gauge",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="visibility",
- name="Visibility",
+ translation_key="visibility",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="windgust",
- name="Wind gust",
+ translation_key="windgust",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="precipitation",
- name="Precipitation",
+ translation_key="precipitation",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
),
SensorEntityDescription(
key="irradiance",
- name="Irradiance",
+ translation_key="irradiance",
device_class=SensorDeviceClass.IRRADIANCE,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="precipitation_forecast_average",
- name="Precipitation forecast average",
+ translation_key="precipitation_forecast_average",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
),
SensorEntityDescription(
key="precipitation_forecast_total",
- name="Precipitation forecast total",
+ translation_key="precipitation_forecast_total",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="rainlast24hour",
- name="Rain last 24h",
+ translation_key="rainlast24hour",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="rainlasthour",
- name="Rain last hour",
+ translation_key="rainlasthour",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="temperature_1d",
- name="Temperature 1d",
+ translation_key="temperature_1d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="temperature_2d",
- name="Temperature 2d",
+ translation_key="temperature_2d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="temperature_3d",
- name="Temperature 3d",
+ translation_key="temperature_3d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="temperature_4d",
- name="Temperature 4d",
+ translation_key="temperature_4d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="temperature_5d",
- name="Temperature 5d",
+ translation_key="temperature_5d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="mintemp_1d",
- name="Minimum temperature 1d",
+ translation_key="mintemp_1d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="mintemp_2d",
- name="Minimum temperature 2d",
+ translation_key="mintemp_2d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="mintemp_3d",
- name="Minimum temperature 3d",
+ translation_key="mintemp_3d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="mintemp_4d",
- name="Minimum temperature 4d",
+ translation_key="mintemp_4d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="mintemp_5d",
- name="Minimum temperature 5d",
+ translation_key="mintemp_5d",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
SensorEntityDescription(
key="rain_1d",
- name="Rain 1d",
+ translation_key="rain_1d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="rain_2d",
- name="Rain 2d",
+ translation_key="rain_2d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="rain_3d",
- name="Rain 3d",
+ translation_key="rain_3d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="rain_4d",
- name="Rain 4d",
+ translation_key="rain_4d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="rain_5d",
- name="Rain 5d",
+ translation_key="rain_5d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="minrain_1d",
- name="Minimum rain 1d",
+ translation_key="minrain_1d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="minrain_2d",
- name="Minimum rain 2d",
+ translation_key="minrain_2d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="minrain_3d",
- name="Minimum rain 3d",
+ translation_key="minrain_3d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="minrain_4d",
- name="Minimum rain 4d",
+ translation_key="minrain_4d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="minrain_5d",
- name="Minimum rain 5d",
+ translation_key="minrain_5d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
# new in json api (>1.0.0):
SensorEntityDescription(
key="maxrain_1d",
- name="Maximum rain 1d",
+ translation_key="maxrain_1d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="maxrain_2d",
- name="Maximum rain 2d",
+ translation_key="maxrain_2d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="maxrain_3d",
- name="Maximum rain 3d",
+ translation_key="maxrain_3d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="maxrain_4d",
- name="Maximum rain 4d",
+ translation_key="maxrain_4d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="maxrain_5d",
- name="Maximum rain 5d",
+ translation_key="maxrain_5d",
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
),
SensorEntityDescription(
key="rainchance_1d",
- name="Rainchance 1d",
+ translation_key="rainchance_1d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-pouring",
),
SensorEntityDescription(
key="rainchance_2d",
- name="Rainchance 2d",
+ translation_key="rainchance_2d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-pouring",
),
SensorEntityDescription(
key="rainchance_3d",
- name="Rainchance 3d",
+ translation_key="rainchance_3d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-pouring",
),
SensorEntityDescription(
key="rainchance_4d",
- name="Rainchance 4d",
+ translation_key="rainchance_4d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-pouring",
),
SensorEntityDescription(
key="rainchance_5d",
- name="Rainchance 5d",
+ translation_key="rainchance_5d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-pouring",
),
SensorEntityDescription(
key="sunchance_1d",
- name="Sunchance 1d",
+ translation_key="sunchance_1d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-partly-cloudy",
),
SensorEntityDescription(
key="sunchance_2d",
- name="Sunchance 2d",
+ translation_key="sunchance_2d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-partly-cloudy",
),
SensorEntityDescription(
key="sunchance_3d",
- name="Sunchance 3d",
+ translation_key="sunchance_3d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-partly-cloudy",
),
SensorEntityDescription(
key="sunchance_4d",
- name="Sunchance 4d",
+ translation_key="sunchance_4d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-partly-cloudy",
),
SensorEntityDescription(
key="sunchance_5d",
- name="Sunchance 5d",
+ translation_key="sunchance_5d",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:weather-partly-cloudy",
),
SensorEntityDescription(
key="windforce_1d",
- name="Wind force 1d",
+ translation_key="windforce_1d",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="windforce_2d",
- name="Wind force 2d",
+ translation_key="windforce_2d",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="windforce_3d",
- name="Wind force 3d",
+ translation_key="windforce_3d",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="windforce_4d",
- name="Wind force 4d",
+ translation_key="windforce_4d",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="windforce_5d",
- name="Wind force 5d",
+ translation_key="windforce_5d",
native_unit_of_measurement="Bft",
icon="mdi:weather-windy",
),
SensorEntityDescription(
key="windspeed_1d",
- name="Wind speed 1d",
+ translation_key="windspeed_1d",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="windspeed_2d",
- name="Wind speed 2d",
+ translation_key="windspeed_2d",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="windspeed_3d",
- name="Wind speed 3d",
+ translation_key="windspeed_3d",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="windspeed_4d",
- name="Wind speed 4d",
+ translation_key="windspeed_4d",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="windspeed_5d",
- name="Wind speed 5d",
+ translation_key="windspeed_5d",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key="winddirection_1d",
- name="Wind direction 1d",
+ translation_key="winddirection_1d",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="winddirection_2d",
- name="Wind direction 2d",
+ translation_key="winddirection_2d",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="winddirection_3d",
- name="Wind direction 3d",
+ translation_key="winddirection_3d",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="winddirection_4d",
- name="Wind direction 4d",
+ translation_key="winddirection_4d",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="winddirection_5d",
- name="Wind direction 5d",
+ translation_key="winddirection_5d",
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth_1d",
- name="Wind direction azimuth 1d",
+ translation_key="windazimuth_1d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth_2d",
- name="Wind direction azimuth 2d",
+ translation_key="windazimuth_2d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth_3d",
- name="Wind direction azimuth 3d",
+ translation_key="windazimuth_3d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth_4d",
- name="Wind direction azimuth 4d",
+ translation_key="windazimuth_4d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="windazimuth_5d",
- name="Wind direction azimuth 5d",
+ translation_key="windazimuth_5d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
),
SensorEntityDescription(
key="condition_1d",
- name="Condition 1d",
+ translation_key="condition_1d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="condition_2d",
- name="Condition 2d",
+ translation_key="condition_2d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="condition_3d",
- name="Condition 3d",
+ translation_key="condition_3d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="condition_4d",
- name="Condition 4d",
+ translation_key="condition_4d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="condition_5d",
- name="Condition 5d",
+ translation_key="condition_5d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITIONS,
),
SensorEntityDescription(
key="conditioncode_1d",
- name="Condition code 1d",
+ translation_key="conditioncode_1d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditioncode_2d",
- name="Condition code 2d",
+ translation_key="conditioncode_2d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditioncode_3d",
- name="Condition code 3d",
+ translation_key="conditioncode_3d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditioncode_4d",
- name="Condition code 4d",
+ translation_key="conditioncode_4d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditioncode_5d",
- name="Condition code 5d",
+ translation_key="conditioncode_5d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_CONDITION_CODES,
),
SensorEntityDescription(
key="conditiondetailed_1d",
- name="Detailed condition 1d",
+ translation_key="conditiondetailed_1d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditiondetailed_2d",
- name="Detailed condition 2d",
+ translation_key="conditiondetailed_2d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditiondetailed_3d",
- name="Detailed condition 3d",
+ translation_key="conditiondetailed_3d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditiondetailed_4d",
- name="Detailed condition 4d",
+ translation_key="conditiondetailed_4d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditiondetailed_5d",
- name="Detailed condition 5d",
+ translation_key="conditiondetailed_5d",
+ device_class=SensorDeviceClass.ENUM,
+ options=STATE_DETAILED_CONDITIONS,
),
SensorEntityDescription(
key="conditionexact_1d",
- name="Full condition 1d",
+ translation_key="conditionexact_1d",
),
SensorEntityDescription(
key="conditionexact_2d",
- name="Full condition 2d",
+ translation_key="conditionexact_2d",
),
SensorEntityDescription(
key="conditionexact_3d",
- name="Full condition 3d",
+ translation_key="conditionexact_3d",
),
SensorEntityDescription(
key="conditionexact_4d",
- name="Full condition 4d",
+ translation_key="conditionexact_4d",
),
SensorEntityDescription(
key="conditionexact_5d",
- name="Full condition 5d",
+ translation_key="conditionexact_5d",
),
SensorEntityDescription(
key="symbol_1d",
- name="Symbol 1d",
+ translation_key="symbol_1d",
),
SensorEntityDescription(
key="symbol_2d",
- name="Symbol 2d",
+ translation_key="symbol_2d",
),
SensorEntityDescription(
key="symbol_3d",
- name="Symbol 3d",
+ translation_key="symbol_3d",
),
SensorEntityDescription(
key="symbol_4d",
- name="Symbol 4d",
+ translation_key="symbol_4d",
),
SensorEntityDescription(
key="symbol_5d",
- name="Symbol 5d",
+ translation_key="symbol_5d",
),
)
@@ -689,17 +732,17 @@ async def async_setup_entry(
class BrSensor(SensorEntity):
- """Representation of an Buienradar sensor."""
+ """Representation of a Buienradar sensor."""
_attr_entity_registry_enabled_default = False
_attr_should_poll = False
+ _attr_has_entity_name = True
def __init__(
self, client_name, coordinates, description: SensorEntityDescription
) -> None:
"""Initialize the sensor."""
self.entity_description = description
- self._attr_name = f"{client_name} {description.name}"
self._measured = None
self._attr_unique_id = "{:2.6f}{:2.6f}{}".format(
coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key
diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json
index 740068a952ba..d7af3b666885 100644
--- a/homeassistant/components/buienradar/strings.json
+++ b/homeassistant/components/buienradar/strings.json
@@ -25,5 +25,483 @@
}
}
}
+ },
+ "entity": {
+ "sensor": {
+ "stationname": {
+ "name": "Station name"
+ },
+ "barometerfc": {
+ "name": "Barometer value"
+ },
+ "barometerfcname": {
+ "name": "Barometer"
+ },
+ "barometerfcnamenl": {
+ "name": "Barometer"
+ },
+ "condition": {
+ "name": "Condition",
+ "state": {
+ "clear": "Clear",
+ "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
+ "fog": "[%key:component::weather::entity_component::_::state::fog%]",
+ "rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
+ "snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
+ "lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
+ }
+ },
+ "conditioncode": {
+ "name": "Condition code"
+ },
+ "conditiondetailed": {
+ "name": "Detailed condition",
+ "state": {
+ "clear": "Clear",
+ "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]",
+ "partlycloudy-fog": "Partly cloudy, fog",
+ "partlycloudy-light-rain": "Partly cloudy, light rain",
+ "partlycloudy-rain": "Partly cloudy, rain",
+ "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]",
+ "fog": "[%key:component::weather::entity_component::_::state::fog%]",
+ "rainy": "[%key:component::weather::entity_component::_::state::rainy%]",
+ "light-rain": "Light rain",
+ "light-snow": "Light snow",
+ "partlycloudy-light-snow": "Partly cloudy, light snow",
+ "partlycloudy-snow": "Partly cloudy, snow",
+ "partlycloudy-lightning": "Partly cloudy, lightning",
+ "snowy": "[%key:component::weather::entity_component::_::state::snowy%]",
+ "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]",
+ "lightning": "[%key:component::weather::entity_component::_::state::lightning%]"
+ }
+ },
+ "conditionexact": {
+ "name": "Full condition"
+ },
+ "symbol": {
+ "name": "Symbol"
+ },
+ "feeltemperature": {
+ "name": "Feel temperature"
+ },
+ "humidity": {
+ "name": "[%key:component::sensor::entity_component::humidity::name%]"
+ },
+ "temperature": {
+ "name": "[%key:component::sensor::entity_component::temperature::name%]"
+ },
+ "groundtemperature": {
+ "name": "Ground temperature"
+ },
+ "windspeed": {
+ "name": "[%key:component::sensor::entity_component::wind_speed::name%]"
+ },
+ "windforce": {
+ "name": "Wind force"
+ },
+ "winddirection": {
+ "name": "Wind direction"
+ },
+ "windazimuth": {
+ "name": "Wind direction azimuth"
+ },
+ "pressure": {
+ "name": "[%key:component::sensor::entity_component::pressure::name%]"
+ },
+ "visibility": {
+ "name": "[%key:component::weather::entity_component::_::state_attributes::visibility::name%]"
+ },
+ "windgust": {
+ "name": "Wind gust"
+ },
+ "precipitation": {
+ "name": "[%key:component::sensor::entity_component::precipitation::name%]"
+ },
+ "irradiance": {
+ "name": "[%key:component::sensor::entity_component::irradiance::name%]"
+ },
+ "precipitation_forecast_average": {
+ "name": "Precipitation forecast average"
+ },
+ "precipitation_forecast_total": {
+ "name": "Precipitation forecast total"
+ },
+ "rainlast24hour": {
+ "name": "Rain last 24h"
+ },
+ "rainlasthour": {
+ "name": "Rain last hour"
+ },
+ "temperature_1d": {
+ "name": "Temperature 1d"
+ },
+ "temperature_2d": {
+ "name": "Temperature 2d"
+ },
+ "temperature_3d": {
+ "name": "Temperature 3d"
+ },
+ "temperature_4d": {
+ "name": "Temperature 4d"
+ },
+ "temperature_5d": {
+ "name": "Temperature 5d"
+ },
+ "mintemp_1d": {
+ "name": "Minimum temperature 1d"
+ },
+ "mintemp_2d": {
+ "name": "Minimum temperature 2d"
+ },
+ "mintemp_3d": {
+ "name": "Minimum temperature 3d"
+ },
+ "mintemp_4d": {
+ "name": "Minimum temperature 4d"
+ },
+ "mintemp_5d": {
+ "name": "Minimum temperature 5d"
+ },
+ "rain_1d": {
+ "name": "Rain 1d"
+ },
+ "rain_2d": {
+ "name": "Rain 2d"
+ },
+ "rain_3d": {
+ "name": "Rain 3d"
+ },
+ "rain_4d": {
+ "name": "Rain 4d"
+ },
+ "rain_5d": {
+ "name": "Rain 5d"
+ },
+ "minrain_1d": {
+ "name": "Minimum rain 1d"
+ },
+ "minrain_2d": {
+ "name": "Minimum rain 2d"
+ },
+ "minrain_3d": {
+ "name": "Minimum rain 3d"
+ },
+ "minrain_4d": {
+ "name": "Minimum rain 4d"
+ },
+ "minrain_5d": {
+ "name": "Minimum rain 5d"
+ },
+ "maxrain_1d": {
+ "name": "Maximum rain 1d"
+ },
+ "maxrain_2d": {
+ "name": "Maximum rain 2d"
+ },
+ "maxrain_3d": {
+ "name": "Maximum rain 3d"
+ },
+ "maxrain_4d": {
+ "name": "Maximum rain 4d"
+ },
+ "maxrain_5d": {
+ "name": "Maximum rain 5d"
+ },
+ "rainchance_1d": {
+ "name": "Rainchance 1d"
+ },
+ "rainchance_2d": {
+ "name": "Rainchance 2d"
+ },
+ "rainchance_3d": {
+ "name": "Rainchance 3d"
+ },
+ "rainchance_4d": {
+ "name": "Rainchance 4d"
+ },
+ "rainchance_5d": {
+ "name": "Rainchance 5d"
+ },
+ "sunchance_1d": {
+ "name": "Sunchance 1d"
+ },
+ "sunchance_2d": {
+ "name": "Sunchance 2d"
+ },
+ "sunchance_3d": {
+ "name": "Sunchance 3d"
+ },
+ "sunchance_4d": {
+ "name": "Sunchance 4d"
+ },
+ "sunchance_5d": {
+ "name": "Sunchance 5d"
+ },
+ "windforce_1d": {
+ "name": "Wind force 1d"
+ },
+ "windforce_2d": {
+ "name": "Wind force 2d"
+ },
+ "windforce_3d": {
+ "name": "Wind force 3d"
+ },
+ "windforce_4d": {
+ "name": "Wind force 4d"
+ },
+ "windforce_5d": {
+ "name": "Wind force 5d"
+ },
+ "windspeed_1d": {
+ "name": "Wind speed 1d"
+ },
+ "windspeed_2d": {
+ "name": "Wind speed 2d"
+ },
+ "windspeed_3d": {
+ "name": "Wind speed 3d"
+ },
+ "windspeed_4d": {
+ "name": "Wind speed 4d"
+ },
+ "windspeed_5d": {
+ "name": "Wind speed 5d"
+ },
+ "winddirection_1d": {
+ "name": "Wind direction 1d"
+ },
+ "winddirection_2d": {
+ "name": "Wind direction 2d"
+ },
+ "winddirection_3d": {
+ "name": "Wind direction 3d"
+ },
+ "winddirection_4d": {
+ "name": "Wind direction 4d"
+ },
+ "winddirection_5d": {
+ "name": "Wind direction 5d"
+ },
+ "windazimuth_1d": {
+ "name": "Wind direction azimuth 1d"
+ },
+ "windazimuth_2d": {
+ "name": "Wind direction azimuth 2d"
+ },
+ "windazimuth_3d": {
+ "name": "Wind direction azimuth 3d"
+ },
+ "windazimuth_4d": {
+ "name": "Wind direction azimuth 4d"
+ },
+ "windazimuth_5d": {
+ "name": "Wind direction azimuth 5d"
+ },
+ "condition_1d": {
+ "name": "Condition 1d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
+ }
+ },
+ "condition_2d": {
+ "name": "Condition 2d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
+ }
+ },
+ "condition_3d": {
+ "name": "Condition 3d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
+ }
+ },
+ "condition_4d": {
+ "name": "Condition 4d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
+ }
+ },
+ "condition_5d": {
+ "name": "Condition 5d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]"
+ }
+ },
+ "conditioncode_1d": {
+ "name": "Condition code 1d"
+ },
+ "conditioncode_2d": {
+ "name": "Condition code 2d"
+ },
+ "conditioncode_3d": {
+ "name": "Condition code 3d"
+ },
+ "conditioncode_4d": {
+ "name": "Condition code 4d"
+ },
+ "conditioncode_5d": {
+ "name": "Condition code 5d"
+ },
+ "conditiondetailed_1d": {
+ "name": "Detailed condition 1d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
+ "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
+ "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
+ "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
+ "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
+ "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
+ "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
+ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
+ "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
+ "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
+ "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
+ }
+ },
+ "conditiondetailed_2d": {
+ "name": "Detailed condition 2d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
+ "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
+ "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
+ "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
+ "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
+ "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
+ "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
+ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
+ "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
+ "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
+ "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
+ }
+ },
+ "conditiondetailed_3d": {
+ "name": "Detailed condition 3d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
+ "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
+ "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
+ "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
+ "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
+ "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
+ "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
+ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
+ "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
+ "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
+ "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
+ }
+ },
+ "conditiondetailed_4d": {
+ "name": "Detailed condition 4d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
+ "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
+ "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
+ "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
+ "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
+ "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
+ "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
+ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
+ "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
+ "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
+ "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
+ }
+ },
+ "conditiondetailed_5d": {
+ "name": "Detailed condition 5d",
+ "state": {
+ "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]",
+ "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]",
+ "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]",
+ "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]",
+ "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]",
+ "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]",
+ "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]",
+ "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]",
+ "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]",
+ "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]",
+ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]",
+ "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]",
+ "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]",
+ "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]",
+ "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]",
+ "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]"
+ }
+ },
+ "conditionexact_1d": {
+ "name": "Full condition 1d"
+ },
+ "conditionexact_2d": {
+ "name": "Full condition 2d"
+ },
+ "conditionexact_3d": {
+ "name": "Full condition 3d"
+ },
+ "conditionexact_4d": {
+ "name": "Full condition 4d"
+ },
+ "conditionexact_5d": {
+ "name": "Full condition 5d"
+ },
+ "symbol_1d": {
+ "name": "Symbol 1d"
+ },
+ "symbol_2d": {
+ "name": "Symbol 2d"
+ },
+ "symbol_3d": {
+ "name": "Symbol 3d"
+ },
+ "symbol_4d": {
+ "name": "Symbol 4d"
+ },
+ "symbol_5d": {
+ "name": "Symbol 5d"
+ }
+ }
}
}
diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json
index 4fd888538937..efad77f5c6df 100644
--- a/homeassistant/components/button/strings.json
+++ b/homeassistant/components/button/strings.json
@@ -18,5 +18,11 @@
"update": {
"name": "Update"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py
index 9a01cd2186ff..e4892ae0383e 100644
--- a/homeassistant/components/caldav/calendar.py
+++ b/homeassistant/components/caldav/calendar.py
@@ -1,7 +1,7 @@
"""Support for WebDav Calendar."""
from __future__ import annotations
-from datetime import date, datetime, timedelta
+from datetime import date, datetime, time, timedelta
from functools import partial
import logging
import re
@@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle, dt
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -204,8 +204,8 @@ class WebDavCalendarData:
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data."""
- start_of_today = dt.start_of_local_day()
- start_of_tomorrow = dt.start_of_local_day() + timedelta(days=self.days)
+ start_of_today = dt_util.start_of_local_day()
+ start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days)
# We have to retrieve the results for the whole day as the server
# won't return events that have already started
@@ -312,7 +312,7 @@ class WebDavCalendarData:
@staticmethod
def is_over(vevent):
"""Return if the event is over."""
- return dt.now() >= WebDavCalendarData.to_datetime(
+ return dt_util.now() >= WebDavCalendarData.to_datetime(
WebDavCalendarData.get_end_date(vevent)
)
@@ -321,9 +321,7 @@ class WebDavCalendarData:
"""Return a datetime."""
if isinstance(obj, datetime):
return WebDavCalendarData.to_local(obj)
- return dt.dt.datetime.combine(obj, dt.dt.time.min).replace(
- tzinfo=dt.DEFAULT_TIME_ZONE
- )
+ return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
@staticmethod
def to_local(obj: datetime | date) -> datetime | date:
@@ -334,7 +332,7 @@ class WebDavCalendarData:
used by the caldav client and dateutil so the datetime can be copied.
"""
if isinstance(obj, datetime):
- return dt.as_local(obj)
+ return dt_util.as_local(obj)
return obj
@staticmethod
diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py
index 0f047bf37585..2cb807169eac 100644
--- a/homeassistant/components/calendar/__init__.py
+++ b/homeassistant/components/calendar/__init__.py
@@ -31,7 +31,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.helpers.typing import ConfigType
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
CONF_EVENT,
@@ -117,7 +117,7 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]
"""Convert all keys that are datetime values to local timezone."""
for k in keys:
if (value := obj.get(k)) and isinstance(value, datetime.datetime):
- obj[k] = dt.as_local(value)
+ obj[k] = dt_util.as_local(value)
return obj
return validate
@@ -294,14 +294,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def get_date(date: dict[str, Any]) -> datetime.datetime:
"""Get the dateTime from date or dateTime as a local."""
if "date" in date:
- parsed_date = dt.parse_date(date["date"])
+ parsed_date = dt_util.parse_date(date["date"])
assert parsed_date
- return dt.start_of_local_day(
+ return dt_util.start_of_local_day(
datetime.datetime.combine(parsed_date, datetime.time.min)
)
- parsed_datetime = dt.parse_datetime(date["dateTime"])
+ parsed_datetime = dt_util.parse_datetime(date["dateTime"])
assert parsed_datetime
- return dt.as_local(parsed_datetime)
+ return dt_util.as_local(parsed_datetime)
@dataclasses.dataclass
@@ -380,7 +380,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for name, value in obj:
if isinstance(value, datetime.datetime):
- result[name] = {"dateTime": dt.as_local(value).isoformat()}
+ result[name] = {"dateTime": dt_util.as_local(value).isoformat()}
elif isinstance(value, datetime.date):
result[name] = {"date": value.isoformat()}
else:
@@ -393,14 +393,14 @@ def _get_datetime_local(
) -> datetime.datetime:
"""Convert a calendar event date/datetime to a datetime if needed."""
if isinstance(dt_or_d, datetime.datetime):
- return dt.as_local(dt_or_d)
- return dt.start_of_local_day(dt_or_d)
+ return dt_util.as_local(dt_or_d)
+ return dt_util.start_of_local_day(dt_or_d)
def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]:
"""Convert a calendar event date/datetime to a datetime if needed."""
if isinstance(dt_or_d, datetime.datetime):
- return {"dateTime": dt.as_local(dt_or_d).isoformat()}
+ return {"dateTime": dt_util.as_local(dt_or_d).isoformat()}
return {"date": dt_or_d.isoformat()}
@@ -433,7 +433,7 @@ def is_offset_reached(
"""Have we reached the offset time specified in the event title."""
if offset_time == datetime.timedelta():
return False
- return start + offset_time <= dt.now(start.tzinfo)
+ return start + offset_time <= dt_util.now(start.tzinfo)
class CalendarEntity(Entity):
@@ -467,7 +467,7 @@ class CalendarEntity(Entity):
if (event := self.event) is None:
return STATE_OFF
- now = dt.now()
+ now = dt_util.now()
if event.start_datetime_local <= now < event.end_datetime_local:
return STATE_ON
@@ -529,8 +529,8 @@ class CalendarEventView(http.HomeAssistantView):
if start is None or end is None:
return web.Response(status=HTTPStatus.BAD_REQUEST)
try:
- start_date = dt.parse_datetime(start)
- end_date = dt.parse_datetime(end)
+ start_date = dt_util.parse_datetime(start)
+ end_date = dt_util.parse_datetime(end)
except (ValueError, AttributeError):
return web.Response(status=HTTPStatus.BAD_REQUEST)
if start_date is None or end_date is None:
@@ -540,7 +540,9 @@ class CalendarEventView(http.HomeAssistantView):
try:
calendar_event_list = await entity.async_get_events(
- request.app["hass"], dt.as_local(start_date), dt.as_local(end_date)
+ request.app["hass"],
+ dt_util.as_local(start_date),
+ dt_util.as_local(end_date),
)
except HomeAssistantError as err:
_LOGGER.debug("Error reading events: %s", err)
diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json
index 898953c18acd..b28f741c3816 100644
--- a/homeassistant/components/calendar/strings.json
+++ b/homeassistant/components/calendar/strings.json
@@ -32,5 +32,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index c09586848dff..b22e2996f7e4 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -673,7 +673,10 @@ class Camera(Entity):
async def async_internal_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_internal_added_to_hass()
- await self.async_refresh_providers()
+ # Avoid calling async_refresh_providers() in here because it
+ # it will write state a second time since state is always
+ # written when an entity is added to hass.
+ self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
async def async_refresh_providers(self) -> None:
"""Determine if any of the registered providers are suitable for this entity.
diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json
index 0722ec1c5e6d..f67097516b46 100644
--- a/homeassistant/components/camera/strings.json
+++ b/homeassistant/components/camera/strings.json
@@ -34,5 +34,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py
index 4d1c00f967b4..aa0bdfa81187 100644
--- a/homeassistant/components/cast/__init__.py
+++ b/homeassistant/components/cast/__init__.py
@@ -1,14 +1,12 @@
"""Component to embed Google Cast."""
from __future__ import annotations
-import logging
from typing import Protocol
from pychromecast import Chromecast
-import voluptuous as vol
from homeassistant.components.media_player import BrowseMedia, MediaType
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -16,44 +14,14 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
-from homeassistant.helpers.typing import ConfigType
from . import home_assistant_cast
from .const import DOMAIN
-from .media_player import ENTITY_SCHEMA
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
-_LOGGER = logging.getLogger(__name__)
-
PLATFORMS = [Platform.MEDIA_PLAYER]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Cast component."""
- if (conf := config.get(DOMAIN)) is not None:
- media_player_config_validated = []
- media_player_config = conf.get("media_player", {})
- if not isinstance(media_player_config, list):
- media_player_config = [media_player_config]
- for cfg in media_player_config:
- try:
- cfg = ENTITY_SCHEMA(cfg)
- media_player_config_validated.append(cfg)
- except vol.Error as ex:
- _LOGGER.warning("Invalid config '%s': %s", cfg, ex)
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=media_player_config_validated,
- )
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Cast from a config entry."""
await home_assistant_cast.async_setup_ha_cast(hass, entry)
diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py
index a5fc43600970..e58bcb71b28e 100644
--- a/homeassistant/components/cast/config_flow.py
+++ b/homeassistant/components/cast/config_flow.py
@@ -38,21 +38,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return CastOptionsFlowHandler(config_entry)
- async def async_step_import(self, import_data=None):
- """Import data."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
- media_player_config = import_data or []
- for cfg in media_player_config:
- if CONF_IGNORE_CEC in cfg:
- self._ignore_cec.update(set(cfg[CONF_IGNORE_CEC]))
- if CONF_UUID in cfg:
- self._wanted_uuid.add(cfg[CONF_UUID])
-
- data = self._get_data()
- return self.async_create_entry(title="Google Cast", data=data)
-
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index b701890d85db..3031eb8365b0 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -23,7 +23,6 @@ from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
-import voluptuous as vol
import yarl
from homeassistant.components import media_source, zeroconf
@@ -47,7 +46,6 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -83,15 +81,6 @@ APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",)
CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"
-ENTITY_SCHEMA = vol.All(
- vol.Schema(
- {
- vol.Optional(CONF_UUID): cv.string,
- vol.Optional(CONF_IGNORE_CEC): vol.All(cv.ensure_list, [cv.string]),
- }
- ),
-)
-
@callback
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py
index 219b5425b5c0..582c6118f575 100644
--- a/homeassistant/components/cert_expiry/helper.py
+++ b/homeassistant/components/cert_expiry/helper.py
@@ -3,7 +3,7 @@ import socket
import ssl
from homeassistant.core import HomeAssistant
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import TIMEOUT
from .errors import (
@@ -52,4 +52,4 @@ async def get_cert_expiry_timestamp(
raise ValidationFailure(err.args[0]) from err
ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
- return dt.utc_from_timestamp(ts_seconds)
+ return dt_util.utc_from_timestamp(ts_seconds)
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index 5e3fe15d5667..00696b0738c0 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -103,5 +103,11 @@
"temperature": { "name": "Target temperature" }
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 0af85fe9d4d0..620e650315a6 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
-from datetime import timedelta
+from datetime import datetime, timedelta
from enum import Enum
from hass_nabucasa import Cloud
@@ -18,7 +18,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
+from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -31,7 +31,6 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-from homeassistant.util.aiohttp import MockRequest
from . import account_link, http_api
from .client import CloudClient
@@ -184,8 +183,10 @@ async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str:
if not async_is_logged_in(hass):
raise CloudNotAvailable
- hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True)
- return hook["cloudhook_url"]
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
+ hook = await cloud.cloudhooks.async_create(webhook_id, True)
+ cloudhook_url: str = hook["cloudhook_url"]
+ return cloudhook_url
@bind_hass
@@ -213,14 +214,6 @@ def async_remote_ui_url(hass: HomeAssistant) -> str:
return f"https://{remote_domain}"
-def is_cloudhook_request(request):
- """Test if a request came from a cloudhook.
-
- Async friendly.
- """
- return isinstance(request, MockRequest)
-
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the Home Assistant cloud."""
# Process configs
@@ -243,7 +236,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
cloud.iot.register_on_connect(client.on_cloud_connected)
- async def _shutdown(event):
+ async def _shutdown(event: Event) -> None:
"""Shutdown event."""
await cloud.stop()
@@ -263,7 +256,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
)
- async def async_startup_repairs(_=None) -> None:
+ async def async_startup_repairs(_: datetime) -> None:
"""Create repair issues after startup."""
if not cloud.is_logged_in:
return
@@ -273,7 +266,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
loaded = False
- async def _on_start():
+ async def _on_start() -> None:
"""Discover platforms."""
nonlocal loaded
@@ -292,19 +285,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config)
await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait())
- async def _on_connect():
+ async def _on_connect() -> None:
"""Handle cloud connect."""
async_dispatcher_send(
hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED
)
- async def _on_disconnect():
+ async def _on_disconnect() -> None:
"""Handle cloud disconnect."""
async_dispatcher_send(
hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED
)
- async def _on_initialized():
+ async def _on_initialized() -> None:
"""Update preferences."""
await prefs.async_update(remote_domain=cloud.remote.instance_domain)
@@ -330,7 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
-def _remote_handle_prefs_updated(cloud: Cloud) -> None:
+def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
"""Handle remote preferences updated."""
cur_pref = cloud.client.prefs.remote_enabled
lock = asyncio.Lock()
diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py
index e3b3c1231bba..1423330cb44f 100644
--- a/homeassistant/components/cloud/account_link.py
+++ b/homeassistant/components/cloud/account_link.py
@@ -1,5 +1,8 @@
"""Account linking via the cloud."""
+from __future__ import annotations
+
import asyncio
+from datetime import datetime
import logging
from typing import Any
@@ -24,14 +27,16 @@ CURRENT_PLAIN_VERSION = AwesomeVersion(
@callback
-def async_setup(hass: HomeAssistant):
+def async_setup(hass: HomeAssistant) -> None:
"""Set up cloud account link."""
config_entry_oauth2_flow.async_add_implementation_provider(
hass, DOMAIN, async_provide_implementation
)
-async def async_provide_implementation(hass: HomeAssistant, domain: str):
+async def async_provide_implementation(
+ hass: HomeAssistant, domain: str
+) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]:
"""Provide an implementation for a domain."""
services = await _get_services(hass)
@@ -55,9 +60,11 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str):
return []
-async def _get_services(hass):
+async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Get the available services."""
- if (services := hass.data.get(DATA_SERVICES)) is not None:
+ services: list[dict[str, Any]]
+ if DATA_SERVICES in hass.data:
+ services = hass.data[DATA_SERVICES]
return services
try:
@@ -68,7 +75,7 @@ async def _get_services(hass):
hass.data[DATA_SERVICES] = services
@callback
- def clear_services(_now):
+ def clear_services(_now: datetime) -> None:
"""Clear services cache."""
hass.data.pop(DATA_SERVICES, None)
@@ -102,7 +109,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
)
authorize_url = await helper.async_get_authorize_url()
- async def await_tokens():
+ async def await_tokens() -> None:
"""Wait for tokens and pass them on when received."""
try:
tokens = await helper.async_get_tokens()
@@ -125,7 +132,8 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve external data to tokens."""
# We already passed in tokens
- return external_data
+ dict_data: dict = external_data
+ return dict_data
async def _async_refresh_token(self, token: dict) -> dict:
"""Refresh a token."""
diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py
index 53bf44d8aa12..8c1300f6228a 100644
--- a/homeassistant/components/cloud/alexa_config.py
+++ b/homeassistant/components/cloud/alexa_config.py
@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from contextlib import suppress
-from datetime import timedelta
+from datetime import datetime, timedelta
from http import HTTPStatus
import logging
+from typing import TYPE_CHECKING, Any
import aiohttp
import async_timeout
@@ -29,10 +30,11 @@ from homeassistant.components.homeassistant.exposed_entities import (
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
-from homeassistant.core import HomeAssistant, callback, split_entity_id
+from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, start
from homeassistant.helpers.entity import get_device_class
+from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.event import async_call_later
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -47,6 +49,9 @@ from .const import (
)
from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences
+if TYPE_CHECKING:
+ from .client import CloudClient
+
_LOGGER = logging.getLogger(__name__)
CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
@@ -132,7 +137,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
config: dict,
cloud_user: str,
prefs: CloudPreferences,
- cloud: Cloud,
+ cloud: Cloud[CloudClient],
) -> None:
"""Initialize the Alexa config."""
super().__init__(hass)
@@ -141,13 +146,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._prefs = prefs
self._cloud = cloud
self._token = None
- self._token_valid = None
+ self._token_valid: datetime | None = None
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None
- self._endpoint = None
+ self._endpoint: Any = None
@property
- def enabled(self):
+ def enabled(self) -> bool:
"""Return if Alexa is enabled."""
return (
self._cloud.is_logged_in
@@ -156,12 +161,12 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
)
@property
- def supports_auth(self):
+ def supports_auth(self) -> bool:
"""Return if config supports auth."""
return True
@property
- def should_report_state(self):
+ def should_report_state(self) -> bool:
"""Return if states should be proactively reported."""
return (
self._prefs.alexa_enabled
@@ -170,7 +175,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
)
@property
- def endpoint(self):
+ def endpoint(self) -> Any | None:
"""Endpoint for report state."""
if self._endpoint is None:
raise ValueError("No endpoint available. Fetch access token first")
@@ -178,22 +183,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
return self._endpoint
@property
- def locale(self):
+ def locale(self) -> str:
"""Return config locale."""
# Not clear how to determine locale atm.
return "en-US"
@property
- def entity_config(self):
+ def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG) or {}
@callback
- def user_identifier(self):
+ def user_identifier(self) -> str:
"""Return an identifier for the user that represents this config."""
return self._cloud_user
- def _migrate_alexa_entity_settings_v1(self):
+ def _migrate_alexa_entity_settings_v1(self) -> None:
"""Migrate alexa entity settings to entity registry options."""
if not self._config[CONF_FILTER].empty_filter:
# Don't migrate if there's a YAML config
@@ -210,12 +215,17 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._should_expose_legacy(entity_id),
)
- async def async_initialize(self):
+ async def async_initialize(self) -> None:
"""Initialize the Alexa config."""
await super().async_initialize()
- async def on_hass_started(hass):
+ async def on_hass_started(hass: HomeAssistant) -> None:
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
+ _LOGGER.info(
+ "Start migration of Alexa settings from v%s to v%s",
+ self._prefs.alexa_settings_version,
+ ALEXA_SETTINGS_VERSION,
+ )
if self._prefs.alexa_settings_version < 2 or (
# Recover from a bug we had in 2023.5.0 where entities didn't get exposed
self._prefs.alexa_settings_version < 3
@@ -228,6 +238,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
):
self._migrate_alexa_entity_settings_v1()
+ _LOGGER.info(
+ "Finished migration of Alexa settings from v%s to v%s",
+ self._prefs.alexa_settings_version,
+ ALEXA_SETTINGS_VERSION,
+ )
await self._prefs.async_update(
alexa_settings_version=ALEXA_SETTINGS_VERSION
)
@@ -235,7 +250,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
)
- async def on_hass_start(hass):
+ async def on_hass_start(hass: HomeAssistant) -> None:
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
@@ -248,14 +263,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._handle_entity_registry_updated,
)
- def _should_expose_legacy(self, entity_id):
+ def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
- entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
+ entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
if entity_expose is not None:
return entity_expose
@@ -279,21 +294,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
)
@callback
- def should_expose(self, entity_id):
+ def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
- if not self._config[CONF_FILTER].empty_filter:
+ entity_filter: EntityFilter = self._config[CONF_FILTER]
+ if not entity_filter.empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
- return self._config[CONF_FILTER](entity_id)
+ return entity_filter(entity_id)
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
@callback
- def async_invalidate_access_token(self):
+ def async_invalidate_access_token(self) -> None:
"""Invalidate access token."""
self._token_valid = None
- async def async_get_access_token(self):
+ async def async_get_access_token(self) -> Any:
"""Get an access token."""
if self._token_valid is not None and self._token_valid > utcnow():
return self._token
@@ -380,7 +396,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self.hass, SYNC_DELAY, self._sync_prefs
)
- async def _sync_prefs(self, _now):
+ async def _sync_prefs(self, _now: datetime) -> None:
"""Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs
@@ -432,7 +448,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
if await self._sync_helper(to_update, to_remove):
self._cur_entity_prefs = new_prefs
- async def async_sync_entities(self):
+ async def async_sync_entities(self) -> bool:
"""Sync all entities to Alexa."""
# Remove any pending sync
if self._alexa_sync_unsub:
@@ -452,7 +468,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
return await self._sync_helper(to_update, to_remove)
- async def _sync_helper(self, to_update, to_remove) -> bool:
+ async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool:
"""Sync entities to Alexa.
Return boolean if it was successful.
@@ -497,7 +513,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
_LOGGER.warning("Error trying to sync entities to Alexa: %s", err)
return False
- async def _handle_entity_registry_updated(self, event):
+ async def _handle_entity_registry_updated(self, event: Event) -> None:
"""Handle when entity registry updated."""
if not self.enabled or not self._cloud.is_logged_in:
return
diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py
index 2d78ad8b512b..e09122ac7bf5 100644
--- a/homeassistant/components/cloud/binary_sensor.py
+++ b/homeassistant/components/cloud/binary_sensor.py
@@ -2,6 +2,10 @@
from __future__ import annotations
import asyncio
+from collections.abc import Callable
+from typing import Any
+
+from hass_nabucasa import Cloud
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -13,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from .client import CloudClient
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
WAIT_UNTIL_CHANGE = 3
@@ -41,10 +46,10 @@ class CloudRemoteBinary(BinarySensorEntity):
_attr_unique_id = "cloud-remote-ui-connectivity"
_attr_entity_category = EntityCategory.DIAGNOSTIC
- def __init__(self, cloud):
+ def __init__(self, cloud: Cloud[CloudClient]) -> None:
"""Initialize the binary sensor."""
self.cloud = cloud
- self._unsub_dispatcher = None
+ self._unsub_dispatcher: Callable[[], None] | None = None
@property
def is_on(self) -> bool:
@@ -59,7 +64,7 @@ class CloudRemoteBinary(BinarySensorEntity):
async def async_added_to_hass(self) -> None:
"""Register update dispatcher."""
- async def async_state_update(data):
+ async def async_state_update(data: Any) -> None:
"""Update callback."""
await asyncio.sleep(WAIT_UNTIL_CHANGE)
self.async_write_ha_state()
diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py
index 631c0641b4fd..dff3bdcdbddf 100644
--- a/homeassistant/components/cloud/client.py
+++ b/homeassistant/components/cloud/client.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
+from datetime import datetime
from http import HTTPStatus
import logging
from pathlib import Path
@@ -16,7 +17,7 @@ from homeassistant.components.alexa import (
smart_home as alexa_smart_home,
)
from homeassistant.components.google_assistant import smart_home as ga
-from homeassistant.core import Context, HomeAssistant, callback
+from homeassistant.core import Context, HassJob, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.util.aiohttp import MockRequest, serialize_response
@@ -76,7 +77,7 @@ class CloudClient(Interface):
return self._hass.http.runner
@property
- def cloudhooks(self) -> dict[str, dict[str, str]]:
+ def cloudhooks(self) -> dict[str, dict[str, str | bool]]:
"""Return list of cloudhooks."""
return self._prefs.cloudhooks
@@ -95,9 +96,9 @@ class CloudClient(Interface):
if self._alexa_config is None:
async with self._alexa_config_init_lock:
if self._alexa_config is not None:
- return self._alexa_config
-
- assert self.cloud is not None
+ # This is reachable if the config was set while we waited
+ # for the lock
+ return self._alexa_config # type: ignore[unreachable]
cloud_user = await self._prefs.get_cloud_user()
@@ -120,8 +121,6 @@ class CloudClient(Interface):
if self._google_config is not None:
return self._google_config
- assert self.cloud is not None
-
cloud_user = await self._prefs.get_cloud_user()
google_conf = google_config.CloudGoogleConfig(
@@ -140,7 +139,7 @@ class CloudClient(Interface):
"""When cloud is connected."""
is_new_user = await self.prefs.async_set_username(self.cloud.username)
- async def enable_alexa(_):
+ async def enable_alexa(_: Any) -> None:
"""Enable Alexa."""
aconf = await self.get_alexa_config()
try:
@@ -154,11 +153,13 @@ class CloudClient(Interface):
),
err,
)
- async_call_later(self._hass, 30, enable_alexa)
+ async_call_later(self._hass, 30, enable_alexa_job)
except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink):
pass
- async def enable_google(_):
+ enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True)
+
+ async def enable_google(_: datetime) -> None:
"""Enable Google."""
gconf = await self.get_google_config()
@@ -212,7 +213,7 @@ class CloudClient(Interface):
"""Process cloud alexa message to client."""
cloud_user = await self._prefs.get_cloud_user()
aconfig = await self.get_alexa_config()
- return await alexa_smart_home.async_handle_message(
+ return await alexa_smart_home.async_handle_message( # type: ignore[no-any-return, no-untyped-call]
self._hass,
aconfig,
payload,
@@ -225,9 +226,11 @@ class CloudClient(Interface):
gconf = await self.get_google_config()
if not self._prefs.google_enabled:
- return ga.api_disabled_response(payload, gconf.agent_user_id)
+ return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call]
+ payload, gconf.agent_user_id
+ )
- return await ga.async_handle_message(
+ return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call]
self._hass, gconf, gconf.cloud_user, payload, google_assistant.SOURCE_CLOUD
)
@@ -270,6 +273,8 @@ class CloudClient(Interface):
if payload and (region := payload.get("region")):
self._relayer_region = region
- async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None:
+ async def async_cloudhooks_update(
+ self, data: dict[str, dict[str, str | bool]]
+ ) -> None:
"""Update local list of cloudhooks."""
await self._prefs.async_update(cloudhooks=data)
diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py
index 351de5d0e654..0a49c0b6ed63 100644
--- a/homeassistant/components/cloud/google_config.py
+++ b/homeassistant/components/cloud/google_config.py
@@ -1,8 +1,10 @@
"""Google config for Cloud."""
+from __future__ import annotations
+
import asyncio
from http import HTTPStatus
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.google_report_state import ErrorResponse
@@ -24,12 +26,14 @@ from homeassistant.core import (
CoreState,
Event,
HomeAssistant,
+ State,
callback,
split_entity_id,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er, start
from homeassistant.helpers.entity import get_device_class
+from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.setup import async_setup_component
from .const import (
@@ -42,6 +46,9 @@ from .const import (
)
from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences
+if TYPE_CHECKING:
+ from .client import CloudClient
+
_LOGGER = logging.getLogger(__name__)
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
@@ -101,7 +108,12 @@ def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool:
if domain in SUPPORTED_DOMAINS:
return True
- device_class = get_device_class(hass, entity_id)
+ try:
+ device_class = get_device_class(hass, entity_id)
+ except HomeAssistantError:
+ # The entity no longer exists
+ return False
+
if (
domain == "binary_sensor"
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
@@ -123,7 +135,7 @@ class CloudGoogleConfig(AbstractConfig):
config: dict[str, Any],
cloud_user: str,
prefs: CloudPreferences,
- cloud: Cloud,
+ cloud: Cloud[CloudClient],
) -> None:
"""Initialize the Google config."""
super().__init__(hass)
@@ -134,7 +146,7 @@ class CloudGoogleConfig(AbstractConfig):
self._sync_entities_lock = asyncio.Lock()
@property
- def enabled(self):
+ def enabled(self) -> bool:
"""Return if Google is enabled."""
return (
self._cloud.is_logged_in
@@ -143,34 +155,34 @@ class CloudGoogleConfig(AbstractConfig):
)
@property
- def entity_config(self):
+ def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG) or {}
@property
- def secure_devices_pin(self):
+ def secure_devices_pin(self) -> str | None:
"""Return entity config."""
return self._prefs.google_secure_devices_pin
@property
- def should_report_state(self):
+ def should_report_state(self) -> bool:
"""Return if states should be proactively reported."""
return self.enabled and self._prefs.google_report_state
- def get_local_webhook_id(self, agent_user_id):
+ def get_local_webhook_id(self, agent_user_id: Any) -> str:
"""Return the webhook ID to be used for actions for a given agent user id via the local SDK."""
return self._prefs.google_local_webhook_id
- def get_local_agent_user_id(self, webhook_id):
+ def get_local_agent_user_id(self, webhook_id: Any) -> str:
"""Return the user ID to be used for actions received via the local SDK."""
return self._user
@property
- def cloud_user(self):
+ def cloud_user(self) -> str:
"""Return Cloud User account."""
return self._user
- def _migrate_google_entity_settings_v1(self):
+ def _migrate_google_entity_settings_v1(self) -> None:
"""Migrate Google entity settings to entity registry options."""
if not self._config[CONF_FILTER].empty_filter:
# Don't migrate if there's a YAML config
@@ -195,12 +207,17 @@ class CloudGoogleConfig(AbstractConfig):
_2fa_disabled,
)
- async def async_initialize(self):
+ async def async_initialize(self) -> None:
"""Perform async initialization of config."""
await super().async_initialize()
async def on_hass_started(hass: HomeAssistant) -> None:
if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
+ _LOGGER.info(
+ "Start migration of Google Assistant settings from v%s to v%s",
+ self._prefs.google_settings_version,
+ GOOGLE_SETTINGS_VERSION,
+ )
if self._prefs.google_settings_version < 2 or (
# Recover from a bug we had in 2023.5.0 where entities didn't get exposed
self._prefs.google_settings_version < 3
@@ -213,6 +230,11 @@ class CloudGoogleConfig(AbstractConfig):
):
self._migrate_google_entity_settings_v1()
+ _LOGGER.info(
+ "Finished migration of Google Assistant settings from v%s to v%s",
+ self._prefs.google_settings_version,
+ GOOGLE_SETTINGS_VERSION,
+ )
await self._prefs.async_update(
google_settings_version=GOOGLE_SETTINGS_VERSION
)
@@ -246,18 +268,18 @@ class CloudGoogleConfig(AbstractConfig):
self._handle_device_registry_updated,
)
- def should_expose(self, state):
+ def should_expose(self, state: State) -> bool:
"""If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id)
- def _should_expose_legacy(self, entity_id):
+ def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity ID should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {})
- entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
+ entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
if entity_expose is not None:
return entity_expose
@@ -282,36 +304,37 @@ class CloudGoogleConfig(AbstractConfig):
and _supported_legacy(self.hass, entity_id)
)
- def _should_expose_entity_id(self, entity_id):
+ def _should_expose_entity_id(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
- if not self._config[CONF_FILTER].empty_filter:
+ entity_filter: EntityFilter = self._config[CONF_FILTER]
+ if not entity_filter.empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
- return self._config[CONF_FILTER](entity_id)
+ return entity_filter(entity_id)
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
@property
- def agent_user_id(self):
+ def agent_user_id(self) -> str:
"""Return Agent User Id to use for query responses."""
return self._cloud.username
@property
- def has_registered_user_agent(self):
+ def has_registered_user_agent(self) -> bool:
"""Return if we have a Agent User Id registered."""
return len(self._store.agent_user_ids) > 0
- def get_agent_user_id(self, context):
+ def get_agent_user_id(self, context: Any) -> str:
"""Get agent user ID making request."""
return self.agent_user_id
- def _2fa_disabled_legacy(self, entity_id):
+ def _2fa_disabled_legacy(self, entity_id: str) -> bool | None:
"""If an entity should be checked for 2FA."""
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(PREF_DISABLE_2FA)
- def should_2fa(self, state):
+ def should_2fa(self, state: State) -> bool:
"""If an entity should be checked for 2FA."""
try:
settings = async_get_entity_settings(self.hass, state.entity_id)
@@ -322,14 +345,14 @@ class CloudGoogleConfig(AbstractConfig):
assistant_options = settings.get(CLOUD_GOOGLE, {})
return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
- async def async_report_state(self, message, agent_user_id: str):
+ async def async_report_state(self, message: Any, agent_user_id: str) -> None:
"""Send a state report to Google."""
try:
await self._cloud.google_report_state.async_send_message(message)
except ErrorResponse as err:
_LOGGER.warning("Error reporting state - %s: %s", err.code, err.message)
- async def _async_request_sync_devices(self, agent_user_id: str):
+ async def _async_request_sync_devices(self, agent_user_id: str) -> int:
"""Trigger a sync with Google."""
if self._sync_entities_lock.locked():
return HTTPStatus.OK
@@ -338,7 +361,7 @@ class CloudGoogleConfig(AbstractConfig):
resp = await cloud_api.async_google_actions_request_sync(self._cloud)
return resp.status
- async def _async_prefs_updated(self, prefs):
+ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
"""Handle updated preferences."""
if not self._cloud.is_logged_in:
if self.is_reporting_state:
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index f5d5c98fe1ac..84c348236d4b 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -1,14 +1,15 @@
"""The HTTP api to control the cloud integration."""
import asyncio
-from collections.abc import Mapping
+from collections.abc import Awaitable, Callable, Coroutine, Mapping
from contextlib import suppress
import dataclasses
from functools import wraps
from http import HTTPStatus
import logging
-from typing import Any
+from typing import Any, Concatenate, ParamSpec, TypeVar
import aiohttp
+from aiohttp import web
import async_timeout
import attr
from hass_nabucasa import Cloud, auth, thingtalk
@@ -32,6 +33,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
+from .client import CloudClient
from .const import (
DOMAIN,
PREF_ALEXA_REPORT_STATE,
@@ -50,7 +52,7 @@ from .subscription import async_subscription_info
_LOGGER = logging.getLogger(__name__)
-_CLOUD_ERRORS = {
+_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = {
asyncio.TimeoutError: (
HTTPStatus.BAD_GATEWAY,
"Unable to reach the Home Assistant cloud.",
@@ -62,7 +64,7 @@ _CLOUD_ERRORS = {
}
-async def async_setup(hass):
+async def async_setup(hass: HomeAssistant) -> None:
"""Initialize the HTTP API."""
websocket_api.async_register_command(hass, websocket_cloud_status)
websocket_api.async_register_command(hass, websocket_subscription)
@@ -107,11 +109,21 @@ async def async_setup(hass):
)
-def _handle_cloud_errors(handler):
+_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView)
+_P = ParamSpec("_P")
+
+
+def _handle_cloud_errors(
+ handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]]
+) -> Callable[
+ Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response]
+]:
"""Webview decorator to handle auth errors."""
@wraps(handler)
- async def error_handler(view, request, *args, **kwargs):
+ async def error_handler(
+ view: _HassViewT, request: web.Request, *args: _P.args, **kwargs: _P.kwargs
+ ) -> web.Response:
"""Handle exceptions that raise from the wrapped request handler."""
try:
result = await handler(view, request, *args, **kwargs)
@@ -126,25 +138,37 @@ def _handle_cloud_errors(handler):
return error_handler
-def _ws_handle_cloud_errors(handler):
+def _ws_handle_cloud_errors(
+ handler: Callable[
+ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]],
+ Coroutine[None, None, None],
+ ]
+) -> Callable[
+ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]],
+ Coroutine[None, None, None],
+]:
"""Websocket decorator to handle auth errors."""
@wraps(handler)
- async def error_handler(hass, connection, msg):
+ async def error_handler(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+ ) -> None:
"""Handle exceptions that raise from the wrapped handler."""
try:
return await handler(hass, connection, msg)
except Exception as err: # pylint: disable=broad-except
err_status, err_msg = _process_cloud_exception(err, msg["type"])
- connection.send_error(msg["id"], err_status, err_msg)
+ connection.send_error(msg["id"], str(err_status), err_msg)
return error_handler
-def _process_cloud_exception(exc, where):
+def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, str]:
"""Process a cloud exception."""
- err_info = None
+ err_info: tuple[HTTPStatus, str] | None = None
for err, value_info in _CLOUD_ERRORS.items():
if isinstance(exc, err):
@@ -165,10 +189,10 @@ class GoogleActionsSyncView(HomeAssistantView):
name = "api:cloud:google_actions/sync"
@_handle_cloud_errors
- async def post(self, request):
+ async def post(self, request: web.Request) -> web.Response:
"""Trigger a Google Actions sync."""
hass = request.app["hass"]
- cloud: Cloud = hass.data[DOMAIN]
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
status = await gconf.async_sync_entities(gconf.agent_user_id)
return self.json({}, status_code=status)
@@ -184,7 +208,7 @@ class CloudLoginView(HomeAssistantView):
@RequestDataValidator(
vol.Schema({vol.Required("email"): str, vol.Required("password"): str})
)
- async def post(self, request, data):
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
def cloud_assist_pipeline(hass: HomeAssistant) -> str | None:
@@ -221,7 +245,7 @@ class CloudLogoutView(HomeAssistantView):
name = "api:cloud:logout"
@_handle_cloud_errors
- async def post(self, request):
+ async def post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
@@ -247,7 +271,7 @@ class CloudRegisterView(HomeAssistantView):
}
)
)
- async def post(self, request, data):
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle registration request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
@@ -283,7 +307,7 @@ class CloudResendConfirmView(HomeAssistantView):
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
- async def post(self, request, data):
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle resending confirm email code request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
@@ -302,7 +326,7 @@ class CloudForgotPasswordView(HomeAssistantView):
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
- async def post(self, request, data):
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle forgot password request."""
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
@@ -330,11 +354,20 @@ async def websocket_cloud_status(
)
-def _require_cloud_login(handler):
+def _require_cloud_login(
+ handler: Callable[
+ [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]],
+ None,
+ ]
+) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]:
"""Websocket decorator that requires cloud to be logged in."""
@wraps(handler)
- def with_cloud_auth(hass, connection, msg):
+ def with_cloud_auth(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+ ) -> None:
"""Require to be logged into the cloud."""
cloud = hass.data[DOMAIN]
if not cloud.is_logged_in:
@@ -467,7 +500,9 @@ async def websocket_hook_delete(
connection.send_message(websocket_api.result_message(msg["id"]))
-async def _account_data(hass: HomeAssistant, cloud: Cloud):
+async def _account_data(
+ hass: HomeAssistant, cloud: Cloud[CloudClient]
+) -> dict[str, Any]:
"""Generate the auth data JSON response."""
assert hass.config.api
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 2dbbc81e4c6f..d352b7226f0e 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -3,10 +3,10 @@
"name": "Home Assistant Cloud",
"after_dependencies": ["assist_pipeline", "google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"],
- "dependencies": ["homeassistant", "http", "webhook"],
+ "dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.66.2"]
+ "requirements": ["hass-nabucasa==0.67.1"]
}
diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py
index 5ccc007e5241..46ddafd48e75 100644
--- a/homeassistant/components/cloud/prefs.py
+++ b/homeassistant/components/cloud/prefs.py
@@ -1,14 +1,15 @@
"""Preference management for cloud."""
from __future__ import annotations
+from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components import webhook
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
-from homeassistant.helpers.typing import UNDEFINED
+from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util.logging import async_create_catching_coro
from .const import (
@@ -63,17 +64,20 @@ class CloudPreferencesStore(Store):
class CloudPreferences:
"""Handle cloud preferences."""
- def __init__(self, hass):
+ _prefs: dict[str, Any]
+
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize cloud prefs."""
self._hass = hass
self._store = CloudPreferencesStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
- self._prefs = None
- self._listeners = []
+ self._listeners: list[
+ Callable[[CloudPreferences], Coroutine[Any, Any, None]]
+ ] = []
self.last_updated: set[str] = set()
- async def async_initialize(self):
+ async def async_initialize(self) -> None:
"""Finish initializing the preferences."""
if (prefs := await self._store.async_load()) is None:
prefs = self._empty_config("")
@@ -89,26 +93,28 @@ class CloudPreferences:
)
@callback
- def async_listen_updates(self, listener):
+ def async_listen_updates(
+ self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]]
+ ) -> None:
"""Listen for updates to the preferences."""
self._listeners.append(listener)
async def async_update(
self,
*,
- google_enabled=UNDEFINED,
- alexa_enabled=UNDEFINED,
- remote_enabled=UNDEFINED,
- google_secure_devices_pin=UNDEFINED,
- cloudhooks=UNDEFINED,
- cloud_user=UNDEFINED,
- alexa_report_state=UNDEFINED,
- google_report_state=UNDEFINED,
- tts_default_voice=UNDEFINED,
- remote_domain=UNDEFINED,
- alexa_settings_version=UNDEFINED,
- google_settings_version=UNDEFINED,
- ):
+ google_enabled: bool | UndefinedType = UNDEFINED,
+ alexa_enabled: bool | UndefinedType = UNDEFINED,
+ remote_enabled: bool | UndefinedType = UNDEFINED,
+ google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
+ cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
+ cloud_user: str | UndefinedType = UNDEFINED,
+ alexa_report_state: bool | UndefinedType = UNDEFINED,
+ google_report_state: bool | UndefinedType = UNDEFINED,
+ tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
+ remote_domain: str | None | UndefinedType = UNDEFINED,
+ alexa_settings_version: int | UndefinedType = UNDEFINED,
+ google_settings_version: int | UndefinedType = UNDEFINED,
+ ) -> None:
"""Update user preferences."""
prefs = {**self._prefs}
@@ -131,7 +137,7 @@ class CloudPreferences:
await self._save_prefs(prefs)
- async def async_set_username(self, username) -> bool:
+ async def async_set_username(self, username: str | None) -> bool:
"""Set the username that is logged in."""
# Logging out.
if username is None:
@@ -154,7 +160,7 @@ class CloudPreferences:
return True
- def as_dict(self):
+ def as_dict(self) -> dict[str, Any]:
"""Return dictionary version."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose,
@@ -170,7 +176,7 @@ class CloudPreferences:
}
@property
- def remote_enabled(self):
+ def remote_enabled(self) -> bool:
"""Return if remote is enabled on start."""
if not self._prefs.get(PREF_ENABLE_REMOTE, False):
return False
@@ -178,17 +184,18 @@ class CloudPreferences:
return True
@property
- def remote_domain(self):
+ def remote_domain(self) -> str | None:
"""Return remote domain."""
return self._prefs.get(PREF_REMOTE_DOMAIN)
@property
- def alexa_enabled(self):
+ def alexa_enabled(self) -> bool:
"""Return if Alexa is enabled."""
- return self._prefs[PREF_ENABLE_ALEXA]
+ alexa_enabled: bool = self._prefs[PREF_ENABLE_ALEXA]
+ return alexa_enabled
@property
- def alexa_report_state(self):
+ def alexa_report_state(self) -> bool:
"""Return if Alexa report state is enabled."""
return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE)
@@ -201,44 +208,48 @@ class CloudPreferences:
return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE)
@property
- def alexa_entity_configs(self):
+ def alexa_entity_configs(self) -> dict[str, Any]:
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
@property
- def alexa_settings_version(self):
+ def alexa_settings_version(self) -> int:
"""Return version of Alexa settings."""
- return self._prefs[PREF_ALEXA_SETTINGS_VERSION]
+ alexa_settings_version: int = self._prefs[PREF_ALEXA_SETTINGS_VERSION]
+ return alexa_settings_version
@property
- def google_enabled(self):
+ def google_enabled(self) -> bool:
"""Return if Google is enabled."""
- return self._prefs[PREF_ENABLE_GOOGLE]
+ google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE]
+ return google_enabled
@property
- def google_report_state(self):
+ def google_report_state(self) -> bool:
"""Return if Google report state is enabled."""
return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE)
@property
- def google_secure_devices_pin(self):
+ def google_secure_devices_pin(self) -> str | None:
"""Return if Google is allowed to unlock locks."""
return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN)
@property
- def google_entity_configs(self):
+ def google_entity_configs(self) -> dict[str, dict[str, Any]]:
"""Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property
- def google_settings_version(self):
+ def google_settings_version(self) -> int:
"""Return version of Google settings."""
- return self._prefs[PREF_GOOGLE_SETTINGS_VERSION]
+ google_settings_version: int = self._prefs[PREF_GOOGLE_SETTINGS_VERSION]
+ return google_settings_version
@property
- def google_local_webhook_id(self):
+ def google_local_webhook_id(self) -> str:
"""Return Google webhook ID to receive local messages."""
- return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID]
+ google_local_webhook_id: str = self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID]
+ return google_local_webhook_id
@property
def google_default_expose(self) -> list[str] | None:
@@ -249,12 +260,12 @@ class CloudPreferences:
return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE)
@property
- def cloudhooks(self):
+ def cloudhooks(self) -> dict[str, Any]:
"""Return the published cloud webhooks."""
return self._prefs.get(PREF_CLOUDHOOKS, {})
@property
- def tts_default_voice(self):
+ def tts_default_voice(self) -> tuple[str, str]:
"""Return the default TTS voice."""
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE)
@@ -281,7 +292,7 @@ class CloudPreferences:
# an image was restored without restoring the cloud prefs.
return await self._hass.auth.async_get_user(user_id)
- async def _save_prefs(self, prefs):
+ async def _save_prefs(self, prefs: dict[str, Any]) -> None:
"""Save preferences to disk."""
self.last_updated = {
key for key, value in prefs.items() if value != self._prefs.get(key)
@@ -294,7 +305,7 @@ class CloudPreferences:
@callback
@staticmethod
- def _empty_config(username):
+ def _empty_config(username: str) -> dict[str, Any]:
"""Return an empty config."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py
index 0864d8b48ad7..f7368731d926 100644
--- a/homeassistant/components/cloud/repairs.py
+++ b/homeassistant/components/cloud/repairs.py
@@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
+from .client import CloudClient
from .const import DOMAIN
from .subscription import async_migrate_paypal_agreement, async_subscription_info
@@ -67,7 +68,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
async def async_step_change_plan(self, _: None = None) -> FlowResult:
"""Wait for the user to authorize the app installation."""
- cloud: Cloud = self.hass.data[DOMAIN]
+ cloud: Cloud[CloudClient] = self.hass.data[DOMAIN]
async def _async_wait_for_plan_change() -> None:
flow_manager = repairs_flow_manager(self.hass)
diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py
index 84e1e088d47d..7b6da8b74039 100644
--- a/homeassistant/components/cloud/stt.py
+++ b/homeassistant/components/cloud/stt.py
@@ -18,15 +18,22 @@ from homeassistant.components.stt import (
SpeechResult,
SpeechResultState,
)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from .client import CloudClient
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-async def async_get_engine(hass, config, discovery_info=None):
+async def async_get_engine(
+ hass: HomeAssistant,
+ config: ConfigType,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> CloudProvider:
"""Set up Cloud speech component."""
- cloud: Cloud = hass.data[DOMAIN]
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
cloud_provider = CloudProvider(cloud)
if discovery_info is not None:
@@ -37,7 +44,7 @@ async def async_get_engine(hass, config, discovery_info=None):
class CloudProvider(Provider):
"""NabuCasa speech API provider."""
- def __init__(self, cloud: Cloud) -> None:
+ def __init__(self, cloud: Cloud[CloudClient]) -> None:
"""Home Assistant NabuCasa Speech to text."""
self.cloud = cloud
diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py
index b85a50b20cd4..633f0c95e1b7 100644
--- a/homeassistant/components/cloud/subscription.py
+++ b/homeassistant/components/cloud/subscription.py
@@ -9,12 +9,13 @@ from aiohttp.client_exceptions import ClientError
import async_timeout
from hass_nabucasa import Cloud, cloud_api
+from .client import CloudClient
from .const import REQUEST_TIMEOUT
_LOGGER = logging.getLogger(__name__)
-async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None:
+async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None:
"""Fetch the subscription info."""
try:
async with async_timeout.timeout(REQUEST_TIMEOUT):
@@ -33,7 +34,9 @@ async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None:
return None
-async def async_migrate_paypal_agreement(cloud: Cloud) -> dict[str, Any] | None:
+async def async_migrate_paypal_agreement(
+ cloud: Cloud[CloudClient],
+) -> dict[str, Any] | None:
"""Migrate a paypal agreement from legacy."""
try:
async with async_timeout.timeout(REQUEST_TIMEOUT):
diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py
index 592338144f36..0dfd69344f3e 100644
--- a/homeassistant/components/cloud/system_health.py
+++ b/homeassistant/components/cloud/system_health.py
@@ -1,4 +1,6 @@
"""Provide info to system health."""
+from typing import Any
+
from hass_nabucasa import Cloud
from homeassistant.components import system_health
@@ -16,12 +18,12 @@ def async_register(
register.async_register_info(system_health_info, "/config/cloud")
-async def system_health_info(hass):
+async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
- cloud: Cloud = hass.data[DOMAIN]
- client: CloudClient = cloud.client
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
+ client = cloud.client
- data = {
+ data: dict[str, Any] = {
"logged_in": cloud.is_logged_in,
}
diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py
index fea2ffca9873..88f24d1290f7 100644
--- a/homeassistant/components/cloud/tts.py
+++ b/homeassistant/components/cloud/tts.py
@@ -1,6 +1,8 @@
-"""Support for the cloud for text to speech service."""
+"""Support for the cloud for text-to-speech service."""
+from __future__ import annotations
import logging
+from typing import Any
from hass_nabucasa import Cloud
from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError
@@ -12,11 +14,15 @@ from homeassistant.components.tts import (
CONF_LANG,
PLATFORM_SCHEMA,
Provider,
+ TtsAudioType,
Voice,
)
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from .client import CloudClient
from .const import DOMAIN
+from .prefs import CloudPreferences
ATTR_GENDER = "gender"
@@ -25,7 +31,7 @@ SUPPORT_LANGUAGES = list(TTS_VOICES)
_LOGGER = logging.getLogger(__name__)
-def validate_lang(value):
+def validate_lang(value: dict[str, Any]) -> dict[str, Any]:
"""Validate chosen gender or language."""
if (lang := value.get(CONF_LANG)) is None:
return value
@@ -52,10 +58,16 @@ PLATFORM_SCHEMA = vol.All(
)
-async def async_get_engine(hass, config, discovery_info=None):
+async def async_get_engine(
+ hass: HomeAssistant,
+ config: ConfigType,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> CloudProvider:
"""Set up Cloud speech component."""
- cloud: Cloud = hass.data[DOMAIN]
+ cloud: Cloud[CloudClient] = hass.data[DOMAIN]
+ language: str | None
+ gender: str | None
if discovery_info is not None:
language = None
gender = None
@@ -72,7 +84,9 @@ async def async_get_engine(hass, config, discovery_info=None):
class CloudProvider(Provider):
"""NabuCasa Cloud speech API provider."""
- def __init__(self, cloud: Cloud, language: str, gender: str) -> None:
+ def __init__(
+ self, cloud: Cloud[CloudClient], language: str | None, gender: str | None
+ ) -> None:
"""Initialize cloud provider."""
self.cloud = cloud
self.name = "Cloud"
@@ -85,22 +99,22 @@ class CloudProvider(Provider):
self._language, self._gender = cloud.client.prefs.tts_default_voice
cloud.client.prefs.async_listen_updates(self._sync_prefs)
- async def _sync_prefs(self, prefs):
+ async def _sync_prefs(self, prefs: CloudPreferences) -> None:
"""Sync preferences."""
self._language, self._gender = prefs.tts_default_voice
@property
- def default_language(self):
+ def default_language(self) -> str | None:
"""Return the default language."""
return self._language
@property
- def supported_languages(self):
+ def supported_languages(self) -> list[str]:
"""Return list of supported languages."""
return SUPPORT_LANGUAGES
@property
- def supported_options(self):
+ def supported_options(self) -> list[str]:
"""Return list of supported options like voice, emotion."""
return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT]
@@ -112,14 +126,16 @@ class CloudProvider(Provider):
return [Voice(voice, voice) for voice in voices]
@property
- def default_options(self):
+ def default_options(self) -> dict[str, Any]:
"""Return a dict include default options."""
return {
ATTR_GENDER: self._gender,
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
}
- async def async_get_tts_audio(self, message, language, options=None):
+ async def async_get_tts_audio(
+ self, message: str, language: str, options: dict[str, Any]
+ ) -> TtsAudioType:
"""Load TTS from NabuCasa Cloud."""
# Process TTS
try:
diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py
index 13415147ef93..c5fdec4d511b 100644
--- a/homeassistant/components/coinbase/const.py
+++ b/homeassistant/components/coinbase/const.py
@@ -157,6 +157,7 @@ WALLETS = {
"LYD": "LYD",
"MAD": "MAD",
"MANA": "MANA",
+ "MASK": "MASK",
"MATIC": "MATIC",
"MDL": "MDL",
"MGA": "MGA",
diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py
index 61ec27b32419..aa2d9cfbfdb2 100644
--- a/homeassistant/components/color_extractor/__init__.py
+++ b/homeassistant/components/color_extractor/__init__.py
@@ -24,6 +24,8 @@ from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
# Extend the existing light.turn_on service schema
SERVICE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py
index fe0640d3efa7..906e28052da5 100644
--- a/homeassistant/components/command_line/__init__.py
+++ b/homeassistant/components/command_line/__init__.py
@@ -1 +1,199 @@
"""The command_line component."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL,
+)
+from homeassistant.components.cover import (
+ DOMAIN as COVER_DOMAIN,
+ SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL,
+)
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
+from homeassistant.components.sensor import (
+ CONF_STATE_CLASS,
+ DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
+ DOMAIN as SENSOR_DOMAIN,
+ SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL,
+ STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA,
+)
+from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
+ SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL,
+)
+from homeassistant.const import (
+ CONF_COMMAND,
+ CONF_COMMAND_CLOSE,
+ CONF_COMMAND_OFF,
+ CONF_COMMAND_ON,
+ CONF_COMMAND_OPEN,
+ CONF_COMMAND_STATE,
+ CONF_COMMAND_STOP,
+ CONF_DEVICE_CLASS,
+ CONF_ICON,
+ CONF_NAME,
+ CONF_PAYLOAD_OFF,
+ CONF_PAYLOAD_ON,
+ CONF_SCAN_INTERVAL,
+ CONF_UNIQUE_ID,
+ CONF_UNIT_OF_MEASUREMENT,
+ CONF_VALUE_TEMPLATE,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.typing import ConfigType
+
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN
+
+BINARY_SENSOR_DEFAULT_NAME = "Binary Command Sensor"
+DEFAULT_PAYLOAD_ON = "ON"
+DEFAULT_PAYLOAD_OFF = "OFF"
+CONF_JSON_ATTRIBUTES = "json_attributes"
+SENSOR_DEFAULT_NAME = "Command Sensor"
+CONF_NOTIFIERS = "notifiers"
+
+PLATFORM_MAPPING = {
+ BINARY_SENSOR_DOMAIN: Platform.BINARY_SENSOR,
+ COVER_DOMAIN: Platform.COVER,
+ NOTIFY_DOMAIN: Platform.NOTIFY,
+ SENSOR_DOMAIN: Platform.SENSOR,
+ SWITCH_DOMAIN: Platform.SWITCH,
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+BINARY_SENSOR_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_COMMAND): cv.string,
+ vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
+ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(
+ CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL
+ ): vol.All(cv.time_period, cv.positive_timedelta),
+ }
+)
+COVER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string,
+ vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string,
+ vol.Optional(CONF_COMMAND_STATE): cv.string,
+ vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All(
+ cv.time_period, cv.positive_timedelta
+ ),
+ }
+)
+NOTIFY_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_COMMAND): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ }
+)
+SENSOR_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_COMMAND): cv.string,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv,
+ vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All(
+ cv.time_period, cv.positive_timedelta
+ ),
+ }
+)
+SWITCH_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string,
+ vol.Optional(CONF_COMMAND_ON, default="true"): cv.string,
+ vol.Optional(CONF_COMMAND_STATE): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON): cv.template,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All(
+ cv.time_period, cv.positive_timedelta
+ ),
+ }
+)
+COMBINED_SCHEMA = vol.Schema(
+ {
+ vol.Optional(BINARY_SENSOR_DOMAIN): BINARY_SENSOR_SCHEMA,
+ vol.Optional(COVER_DOMAIN): COVER_SCHEMA,
+ vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA,
+ vol.Optional(SENSOR_DOMAIN): SENSOR_SCHEMA,
+ vol.Optional(SWITCH_DOMAIN): SWITCH_SCHEMA,
+ }
+)
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Optional(DOMAIN): vol.All(
+ cv.ensure_list,
+ [COMBINED_SCHEMA],
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Command Line from yaml config."""
+ command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, [])
+ if not command_line_config:
+ return True
+
+ _LOGGER.debug("Full config loaded: %s", command_line_config)
+
+ load_coroutines: list[Coroutine[Any, Any, None]] = []
+ platforms: list[Platform] = []
+ for platform_config in command_line_config:
+ for platform, _config in platform_config.items():
+ if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms:
+ platforms.append(mapped_platform)
+ _LOGGER.debug(
+ "Loading config %s for platform %s",
+ platform_config,
+ PLATFORM_MAPPING[platform],
+ )
+ load_coroutines.append(
+ discovery.async_load_platform(
+ hass,
+ PLATFORM_MAPPING[platform],
+ DOMAIN,
+ _config,
+ config,
+ )
+ )
+
+ await async_setup_reload_service(hass, DOMAIN, platforms)
+
+ if load_coroutines:
+ _LOGGER.debug("Loading platforms: %s", platforms)
+ await asyncio.gather(*load_coroutines)
+
+ return True
diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py
index 0c2edb8f1912..8abe401ec9ce 100644
--- a/homeassistant/components/command_line/binary_sensor.py
+++ b/homeassistant/components/command_line/binary_sensor.py
@@ -1,12 +1,14 @@
"""Support for custom shell commands to retrieve values."""
from __future__ import annotations
+import asyncio
from datetime import timedelta
import voluptuous as vol
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
+ DOMAIN as BINARY_SENSOR_DOMAIN,
PLATFORM_SCHEMA,
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -17,17 +19,19 @@ from homeassistant.const import (
CONF_NAME,
CONF_PAYLOAD_OFF,
CONF_PAYLOAD_ON,
+ CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER
from .sensor import CommandSensorData
DEFAULT_NAME = "Binary Command Sensor"
@@ -59,16 +63,33 @@ async def async_setup_platform(
) -> None:
"""Set up the Command line Binary Sensor."""
- await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ if binary_sensor_config := config:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_binary_sensor",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_platform_yaml",
+ translation_placeholders={"platform": BINARY_SENSOR_DOMAIN},
+ )
+ if discovery_info:
+ binary_sensor_config = discovery_info
- name: str = config.get(CONF_NAME, DEFAULT_NAME)
- command: str = config[CONF_COMMAND]
- payload_off: str = config[CONF_PAYLOAD_OFF]
- payload_on: str = config[CONF_PAYLOAD_ON]
- device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
- value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- command_timeout: int = config[CONF_COMMAND_TIMEOUT]
- unique_id: str | None = config.get(CONF_UNIQUE_ID)
+ name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME)
+ command: str = binary_sensor_config[CONF_COMMAND]
+ payload_off: str = binary_sensor_config[CONF_PAYLOAD_OFF]
+ payload_on: str = binary_sensor_config[CONF_PAYLOAD_ON]
+ device_class: BinarySensorDeviceClass | None = binary_sensor_config.get(
+ CONF_DEVICE_CLASS
+ )
+ value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE)
+ command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT]
+ unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID)
+ scan_interval: timedelta = binary_sensor_config.get(
+ CONF_SCAN_INTERVAL, SCAN_INTERVAL
+ )
if value_template is not None:
value_template.hass = hass
data = CommandSensorData(hass, command, command_timeout)
@@ -83,15 +104,17 @@ async def async_setup_platform(
payload_off,
value_template,
unique_id,
+ scan_interval,
)
],
- True,
)
class CommandBinarySensor(BinarySensorEntity):
"""Representation of a command line binary sensor."""
+ _attr_should_poll = False
+
def __init__(
self,
data: CommandSensorData,
@@ -101,6 +124,7 @@ class CommandBinarySensor(BinarySensorEntity):
payload_off: str,
value_template: Template | None,
unique_id: str | None,
+ scan_interval: timedelta,
) -> None:
"""Initialize the Command line binary sensor."""
self.data = data
@@ -111,8 +135,39 @@ class CommandBinarySensor(BinarySensorEntity):
self._payload_off = payload_off
self._value_template = value_template
self._attr_unique_id = unique_id
+ self._scan_interval = scan_interval
+ self._process_updates: asyncio.Lock | None = None
- async def async_update(self) -> None:
+ async def async_added_to_hass(self) -> None:
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self._update_entity_state(None)
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass,
+ self._update_entity_state,
+ self._scan_interval,
+ name=f"Command Line Binary Sensor - {self.name}",
+ cancel_on_shutdown=True,
+ ),
+ )
+
+ async def _update_entity_state(self, now) -> None:
+ """Update the state of the entity."""
+ if self._process_updates is None:
+ self._process_updates = asyncio.Lock()
+ if self._process_updates.locked():
+ LOGGER.warning(
+ "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s",
+ self.name,
+ self._scan_interval,
+ )
+ return
+
+ async with self._process_updates:
+ await self._async_update()
+
+ async def _async_update(self) -> None:
"""Get the latest data and updates the state."""
await self.hass.async_add_executor_job(self.data.update)
value = self.data.value
@@ -126,3 +181,5 @@ class CommandBinarySensor(BinarySensorEntity):
self._attr_is_on = True
elif value == self._payload_off:
self._attr_is_on = False
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py
index 4394f3889105..ff51cb7e3313 100644
--- a/homeassistant/components/command_line/const.py
+++ b/homeassistant/components/command_line/const.py
@@ -1,7 +1,11 @@
"""Allows to configure custom shell commands to turn a value for a sensor."""
+import logging
+
from homeassistant.const import Platform
+LOGGER = logging.getLogger(__package__)
+
CONF_COMMAND_TIMEOUT = "command_timeout"
DEFAULT_TIMEOUT = 15
DOMAIN = "command_line"
diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py
index e477affc8541..2d2dc8c5fc2b 100644
--- a/homeassistant/components/command_line/cover.py
+++ b/homeassistant/components/command_line/cover.py
@@ -1,12 +1,17 @@
"""Support for command line covers."""
from __future__ import annotations
-import logging
+import asyncio
+from datetime import timedelta
from typing import TYPE_CHECKING, Any
import voluptuous as vol
-from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity
+from homeassistant.components.cover import (
+ DOMAIN as COVER_DOMAIN,
+ PLATFORM_SCHEMA,
+ CoverEntity,
+)
from homeassistant.const import (
CONF_COMMAND_CLOSE,
CONF_COMMAND_OPEN,
@@ -14,20 +19,24 @@ from homeassistant.const import (
CONF_COMMAND_STOP,
CONF_COVERS,
CONF_FRIENDLY_NAME,
+ CONF_NAME,
+ CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util import slugify
-from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER
from .utils import call_shell_with_timeout, check_output_or_log
-_LOGGER = logging.getLogger(__name__)
+SCAN_INTERVAL = timedelta(seconds=15)
COVER_SCHEMA = vol.Schema(
{
@@ -55,19 +64,35 @@ async def async_setup_platform(
) -> None:
"""Set up cover controlled by shell commands."""
- await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
-
- devices: dict[str, Any] = config.get(CONF_COVERS, {})
covers = []
+ if discovery_info:
+ entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info}
+ else:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_cover",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_platform_yaml",
+ translation_placeholders={"platform": COVER_DOMAIN},
+ )
+ entities = config.get(CONF_COVERS, {})
- for device_name, device_config in devices.items():
+ for device_name, device_config in entities.items():
value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE)
if value_template is not None:
value_template.hass = hass
+ if name := device_config.get(
+ CONF_FRIENDLY_NAME
+ ): # Backward compatibility. Can be removed after deprecation
+ device_config[CONF_NAME] = name
+
covers.append(
CommandCover(
- device_config.get(CONF_FRIENDLY_NAME, device_name),
+ device_config.get(CONF_NAME, device_name),
device_config[CONF_COMMAND_OPEN],
device_config[CONF_COMMAND_CLOSE],
device_config[CONF_COMMAND_STOP],
@@ -75,11 +100,12 @@ async def async_setup_platform(
value_template,
device_config[CONF_COMMAND_TIMEOUT],
device_config.get(CONF_UNIQUE_ID),
+ device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
)
)
if not covers:
- _LOGGER.error("No covers added")
+ LOGGER.error("No covers added")
return
async_add_entities(covers)
@@ -88,6 +114,8 @@ async def async_setup_platform(
class CommandCover(CoverEntity):
"""Representation a command line cover."""
+ _attr_should_poll = False
+
def __init__(
self,
name: str,
@@ -98,6 +126,7 @@ class CommandCover(CoverEntity):
value_template: Template | None,
timeout: int,
unique_id: str | None,
+ scan_interval: timedelta,
) -> None:
"""Initialize the cover."""
self._attr_name = name
@@ -109,17 +138,32 @@ class CommandCover(CoverEntity):
self._value_template = value_template
self._timeout = timeout
self._attr_unique_id = unique_id
- self._attr_should_poll = bool(command_state)
+ self._scan_interval = scan_interval
+ self._process_updates: asyncio.Lock | None = None
+
+ async def async_added_to_hass(self) -> None:
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ if self._command_state:
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass,
+ self._update_entity_state,
+ self._scan_interval,
+ name=f"Command Line Cover - {self.name}",
+ cancel_on_shutdown=True,
+ ),
+ )
def _move_cover(self, command: str) -> bool:
"""Execute the actual commands."""
- _LOGGER.info("Running command: %s", command)
+ LOGGER.info("Running command: %s", command)
returncode = call_shell_with_timeout(command, self._timeout)
success = returncode == 0
if not success:
- _LOGGER.error(
+ LOGGER.error(
"Command failed (with return code %s): %s", returncode, command
)
@@ -143,12 +187,27 @@ class CommandCover(CoverEntity):
def _query_state(self) -> str | None:
"""Query for the state."""
if self._command_state:
- _LOGGER.info("Running state value command: %s", self._command_state)
+ LOGGER.info("Running state value command: %s", self._command_state)
return check_output_or_log(self._command_state, self._timeout)
if TYPE_CHECKING:
return None
- async def async_update(self) -> None:
+ async def _update_entity_state(self, now) -> None:
+ """Update the state of the entity."""
+ if self._process_updates is None:
+ self._process_updates = asyncio.Lock()
+ if self._process_updates.locked():
+ LOGGER.warning(
+ "Updating Command Line Cover %s took longer than the scheduled update interval %s",
+ self.name,
+ self._scan_interval,
+ )
+ return
+
+ async with self._process_updates:
+ await self._async_update()
+
+ async def _async_update(self) -> None:
"""Update device state."""
if self._command_state:
payload = str(await self.hass.async_add_executor_job(self._query_state))
@@ -159,15 +218,19 @@ class CommandCover(CoverEntity):
self._state = None
if payload:
self._state = int(payload)
+ await self.async_update_ha_state(True)
- def open_cover(self, **kwargs: Any) -> None:
+ async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- self._move_cover(self._command_open)
+ await self.hass.async_add_executor_job(self._move_cover, self._command_open)
+ await self._update_entity_state(None)
- def close_cover(self, **kwargs: Any) -> None:
+ async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- self._move_cover(self._command_close)
+ await self.hass.async_add_executor_job(self._move_cover, self._command_close)
+ await self._update_entity_state(None)
- def stop_cover(self, **kwargs: Any) -> None:
+ async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
- self._move_cover(self._command_stop)
+ await self.hass.async_add_executor_job(self._move_cover, self._command_stop)
+ await self._update_entity_state(None)
diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json
index 998c02aad9ec..e99234bed1b9 100644
--- a/homeassistant/components/command_line/manifest.json
+++ b/homeassistant/components/command_line/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "command_line",
"name": "Command Line",
- "codeowners": [],
+ "codeowners": ["@gjohansson-ST"],
"documentation": "https://www.home-assistant.io/integrations/command_line",
"iot_class": "local_polling"
}
diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py
index 412456ff6e5f..2922b8caae3b 100644
--- a/homeassistant/components/command_line/notify.py
+++ b/homeassistant/components/command_line/notify.py
@@ -7,14 +7,19 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService
+from homeassistant.components.notify import (
+ DOMAIN as NOTIFY_DOMAIN,
+ PLATFORM_SCHEMA,
+ BaseNotificationService,
+)
from homeassistant.const import CONF_COMMAND, CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
-from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,8 +38,21 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> CommandLineNotificationService:
"""Get the Command Line notification service."""
- command: str = config[CONF_COMMAND]
- timeout: int = config[CONF_COMMAND_TIMEOUT]
+ if notify_config := config:
+ create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_notify",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_platform_yaml",
+ translation_placeholders={"platform": NOTIFY_DOMAIN},
+ )
+ if discovery_info:
+ notify_config = discovery_info
+ command: str = notify_config[CONF_COMMAND]
+ timeout: int = notify_config[CONF_COMMAND_TIMEOUT]
return CommandLineNotificationService(command, timeout)
diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py
index b6a2b8d83faa..f42ac0620810 100644
--- a/homeassistant/components/command_line/sensor.py
+++ b/homeassistant/components/command_line/sensor.py
@@ -1,16 +1,17 @@
"""Allows to configure custom shell commands to turn a value for a sensor."""
from __future__ import annotations
+import asyncio
from collections.abc import Mapping
from datetime import timedelta
import json
-import logging
import voluptuous as vol
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA,
+ DOMAIN as SENSOR_DOMAIN,
PLATFORM_SCHEMA,
STATE_CLASSES_SCHEMA,
SensorEntity,
@@ -19,6 +20,7 @@ from homeassistant.const import (
CONF_COMMAND,
CONF_DEVICE_CLASS,
CONF_NAME,
+ CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
@@ -27,15 +29,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER
from .utils import check_output_or_log
-_LOGGER = logging.getLogger(__name__)
-
CONF_JSON_ATTRIBUTES = "json_attributes"
DEFAULT_NAME = "Command Sensor"
@@ -64,18 +65,30 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Command Sensor."""
+ if sensor_config := config:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_sensor",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_platform_yaml",
+ translation_placeholders={"platform": SENSOR_DOMAIN},
+ )
+ if discovery_info:
+ sensor_config = discovery_info
- await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
-
- name: str = config[CONF_NAME]
- command: str = config[CONF_COMMAND]
- unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT)
- value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- command_timeout: int = config[CONF_COMMAND_TIMEOUT]
- unique_id: str | None = config.get(CONF_UNIQUE_ID)
+ name: str = sensor_config[CONF_NAME]
+ command: str = sensor_config[CONF_COMMAND]
+ unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT)
+ value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE)
+ command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT]
+ unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID)
if value_template is not None:
value_template.hass = hass
- json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES)
+ json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES)
+ scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
data = CommandSensorData(hass, command, command_timeout)
async_add_entities(
@@ -87,15 +100,17 @@ async def async_setup_platform(
value_template,
json_attributes,
unique_id,
+ scan_interval,
)
- ],
- True,
+ ]
)
class CommandSensor(SensorEntity):
"""Representation of a sensor that is using shell commands."""
+ _attr_should_poll = False
+
def __init__(
self,
data: CommandSensorData,
@@ -104,6 +119,7 @@ class CommandSensor(SensorEntity):
value_template: Template | None,
json_attributes: list[str] | None,
unique_id: str | None,
+ scan_interval: timedelta,
) -> None:
"""Initialize the sensor."""
self._attr_name = name
@@ -114,8 +130,39 @@ class CommandSensor(SensorEntity):
self._value_template = value_template
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = unique_id
+ self._scan_interval = scan_interval
+ self._process_updates: asyncio.Lock | None = None
- async def async_update(self) -> None:
+ async def async_added_to_hass(self) -> None:
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self._update_entity_state(None)
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass,
+ self._update_entity_state,
+ self._scan_interval,
+ name=f"Command Line Sensor - {self.name}",
+ cancel_on_shutdown=True,
+ ),
+ )
+
+ async def _update_entity_state(self, now) -> None:
+ """Update the state of the entity."""
+ if self._process_updates is None:
+ self._process_updates = asyncio.Lock()
+ if self._process_updates.locked():
+ LOGGER.warning(
+ "Updating Command Line Sensor %s took longer than the scheduled update interval %s",
+ self.name,
+ self._scan_interval,
+ )
+ return
+
+ async with self._process_updates:
+ await self._async_update()
+
+ async def _async_update(self) -> None:
"""Get the latest data and updates the state."""
await self.hass.async_add_executor_job(self.data.update)
value = self.data.value
@@ -132,11 +179,11 @@ class CommandSensor(SensorEntity):
if k in json_dict
}
else:
- _LOGGER.warning("JSON result was not a dictionary")
+ LOGGER.warning("JSON result was not a dictionary")
except ValueError:
- _LOGGER.warning("Unable to parse output as JSON: %s", value)
+ LOGGER.warning("Unable to parse output as JSON: %s", value)
else:
- _LOGGER.warning("Empty reply found when expecting JSON data")
+ LOGGER.warning("Empty reply found when expecting JSON data")
if self._value_template is None:
self._attr_native_value = None
return
@@ -151,6 +198,8 @@ class CommandSensor(SensorEntity):
else:
self._attr_native_value = value
+ self.async_write_ha_state()
+
class CommandSensorData:
"""The class for handling the data retrieval."""
@@ -179,7 +228,7 @@ class CommandSensorData:
args_to_render = {"arguments": args}
rendered_args = args_compiled.render(args_to_render)
except TemplateError as ex:
- _LOGGER.exception("Error rendering command template: %s", ex)
+ LOGGER.exception("Error rendering command template: %s", ex)
return
else:
rendered_args = None
@@ -191,5 +240,5 @@ class CommandSensorData:
# Template used. Construct the string used in the shell
command = f"{prog} {rendered_args}"
- _LOGGER.debug("Running command: %s", command)
+ LOGGER.debug("Running command: %s", command)
self.value = check_output_or_log(command, self.timeout)
diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json
new file mode 100644
index 000000000000..dab4a77a6ec3
--- /dev/null
+++ b/homeassistant/components/command_line/strings.json
@@ -0,0 +1,8 @@
+{
+ "issues": {
+ "deprecated_platform_yaml": {
+ "title": "Command Line YAML configuration has moved",
+ "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py
index 3c344891fbad..1a3dd39a342d 100644
--- a/homeassistant/components/command_line/switch.py
+++ b/homeassistant/components/command_line/switch.py
@@ -1,12 +1,14 @@
"""Support for custom shell commands to turn a switch on/off."""
from __future__ import annotations
-import logging
+import asyncio
+from datetime import timedelta
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA,
SwitchEntity,
@@ -16,7 +18,10 @@ from homeassistant.const import (
CONF_COMMAND_ON,
CONF_COMMAND_STATE,
CONF_FRIENDLY_NAME,
+ CONF_ICON,
CONF_ICON_TEMPLATE,
+ CONF_NAME,
+ CONF_SCAN_INTERVAL,
CONF_SWITCHES,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
@@ -24,14 +29,17 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
+from homeassistant.helpers.template_entity import ManualTriggerEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.util import slugify
-from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
+from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER
from .utils import call_shell_with_timeout, check_output_or_log
-_LOGGER = logging.getLogger(__name__)
+SCAN_INTERVAL = timedelta(seconds=30)
SWITCH_SCHEMA = vol.Schema(
{
@@ -59,73 +67,110 @@ async def async_setup_platform(
) -> None:
"""Find and return switches controlled by shell commands."""
- await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ if discovery_info:
+ entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info}
+ else:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_switch",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_platform_yaml",
+ translation_placeholders={"platform": SWITCH_DOMAIN},
+ )
+ entities = config.get(CONF_SWITCHES, {})
- devices: dict[str, Any] = config.get(CONF_SWITCHES, {})
switches = []
- for object_id, device_config in devices.items():
+ for object_id, device_config in entities.items():
+ if name := device_config.get(
+ CONF_FRIENDLY_NAME
+ ): # Backward compatibility. Can be removed after deprecation
+ device_config[CONF_NAME] = name
+
+ if icon := device_config.get(
+ CONF_ICON_TEMPLATE
+ ): # Backward compatibility. Can be removed after deprecation
+ device_config[CONF_ICON] = icon
+
+ trigger_entity_config = {
+ CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID),
+ CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass),
+ CONF_ICON: device_config.get(CONF_ICON),
+ }
+
value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE)
if value_template is not None:
value_template.hass = hass
- icon_template: Template | None = device_config.get(CONF_ICON_TEMPLATE)
- if icon_template is not None:
- icon_template.hass = hass
-
switches.append(
CommandSwitch(
+ trigger_entity_config,
object_id,
- device_config.get(CONF_FRIENDLY_NAME, object_id),
device_config[CONF_COMMAND_ON],
device_config[CONF_COMMAND_OFF],
device_config.get(CONF_COMMAND_STATE),
- icon_template,
value_template,
device_config[CONF_COMMAND_TIMEOUT],
- device_config.get(CONF_UNIQUE_ID),
+ device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
)
)
if not switches:
- _LOGGER.error("No switches added")
+ LOGGER.error("No switches added")
return
async_add_entities(switches)
-class CommandSwitch(SwitchEntity):
+class CommandSwitch(ManualTriggerEntity, SwitchEntity):
"""Representation a switch that can be toggled using shell commands."""
+ _attr_should_poll = False
+
def __init__(
self,
+ config: ConfigType,
object_id: str,
- friendly_name: str,
command_on: str,
command_off: str,
command_state: str | None,
- icon_template: Template | None,
value_template: Template | None,
timeout: int,
- unique_id: str | None,
+ scan_interval: timedelta,
) -> None:
"""Initialize the switch."""
+ super().__init__(self.hass, config)
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self._attr_name = friendly_name
self._attr_is_on = False
self._command_on = command_on
self._command_off = command_off
self._command_state = command_state
- self._icon_template = icon_template
self._value_template = value_template
self._timeout = timeout
- self._attr_unique_id = unique_id
- self._attr_should_poll = bool(command_state)
+ self._scan_interval = scan_interval
+ self._process_updates: asyncio.Lock | None = None
+
+ async def async_added_to_hass(self) -> None:
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ if self._command_state:
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass,
+ self._update_entity_state,
+ self._scan_interval,
+ name=f"Command Line Cover - {self.name}",
+ cancel_on_shutdown=True,
+ ),
+ )
async def _switch(self, command: str) -> bool:
"""Execute the actual commands."""
- _LOGGER.info("Running command: %s", command)
+ LOGGER.info("Running command: %s", command)
success = (
await self.hass.async_add_executor_job(
@@ -135,18 +180,18 @@ class CommandSwitch(SwitchEntity):
)
if not success:
- _LOGGER.error("Command failed: %s", command)
+ LOGGER.error("Command failed: %s", command)
return success
def _query_state_value(self, command: str) -> str | None:
"""Execute state command for return value."""
- _LOGGER.info("Running state value command: %s", command)
+ LOGGER.info("Running state value command: %s", command)
return check_output_or_log(command, self._timeout)
def _query_state_code(self, command: str) -> bool:
"""Execute state command for return code."""
- _LOGGER.info("Running state code command: %s", command)
+ LOGGER.info("Running state code command: %s", command)
return (
call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0
)
@@ -165,30 +210,46 @@ class CommandSwitch(SwitchEntity):
if TYPE_CHECKING:
return None
- async def async_update(self) -> None:
+ async def _update_entity_state(self, now) -> None:
+ """Update the state of the entity."""
+ if self._process_updates is None:
+ self._process_updates = asyncio.Lock()
+ if self._process_updates.locked():
+ LOGGER.warning(
+ "Updating Command Line Switch %s took longer than the scheduled update interval %s",
+ self.name,
+ self._scan_interval,
+ )
+ return
+
+ async with self._process_updates:
+ await self._async_update()
+
+ async def _async_update(self) -> None:
"""Update device state."""
if self._command_state:
payload = str(await self.hass.async_add_executor_job(self._query_state))
- if self._icon_template:
- self._attr_icon = (
- self._icon_template.async_render_with_possible_json_value(payload)
- )
+ value = None
if self._value_template:
- payload = self._value_template.async_render_with_possible_json_value(
+ value = self._value_template.async_render_with_possible_json_value(
payload, None
)
self._attr_is_on = None
- if payload:
- self._attr_is_on = payload.lower() == "true"
+ if payload or value:
+ self._attr_is_on = (value or payload).lower() == "true"
+ self._process_manual_data(payload)
+ await self.async_update_ha_state(True)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
if await self._switch(self._command_on) and not self._command_state:
self._attr_is_on = True
self.async_schedule_update_ha_state()
+ await self._update_entity_state(None)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
if await self._switch(self._command_off) and not self._command_state:
self._attr_is_on = False
self.async_schedule_update_ha_state()
+ await self._update_entity_state(None)
diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py
index def7edd4950c..514154137e4c 100644
--- a/homeassistant/components/config/__init__.py
+++ b/homeassistant/components/config/__init__.py
@@ -11,6 +11,7 @@ from homeassistant.components.http import HomeAssistantView
from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import ATTR_COMPONENT
from homeassistant.util.file import write_utf8_file_atomic
@@ -32,6 +33,8 @@ SECTIONS = (
ACTION_CREATE_UPDATE = "create_update"
ACTION_DELETE = "delete"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the config component."""
diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py
index c6fd4003156c..d58616ff38f4 100644
--- a/homeassistant/components/config/config_entries.py
+++ b/homeassistant/components/config/config_entries.py
@@ -43,6 +43,7 @@ async def async_setup(hass):
websocket_api.async_register_command(hass, config_entries_get)
websocket_api.async_register_command(hass, config_entry_disable)
+ websocket_api.async_register_command(hass, config_entry_get_single)
websocket_api.async_register_command(hass, config_entry_update)
websocket_api.async_register_command(hass, config_entries_subscribe)
websocket_api.async_register_command(hass, config_entries_progress)
@@ -284,6 +285,28 @@ def get_entry(
return entry
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ "type": "config_entries/get_single",
+ "entry_id": str,
+ }
+)
+@websocket_api.async_response
+async def config_entry_get_single(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Update config entry."""
+ entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
+ if entry is None:
+ return
+
+ result = {"config_entry": entry_json(entry)}
+ connection.send_result(msg["id"], result)
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py
index ea785b5cdfbe..6fd3917cc9c8 100644
--- a/homeassistant/components/configurator/__init__.py
+++ b/homeassistant/components/configurator/__init__.py
@@ -15,6 +15,7 @@ from typing import Any
from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant, ServiceCall, callback as async_callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
@@ -44,6 +45,8 @@ STATE_CONFIGURED = "configured"
ConfiguratorCallback = Callable[[list[dict[str, str]]], None]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@bind_hass
@async_callback
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index dccf394ab3fa..44b13522412a 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -31,7 +31,7 @@ from homeassistant.helpers import (
template,
translation,
)
-from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
@@ -83,22 +83,16 @@ def async_setup(hass: core.HomeAssistant) -> None:
async_should_expose(hass, DOMAIN, entity_id)
@core.callback
- def async_entity_state_listener(
- changed_entity: str,
- old_state: core.State | None,
- new_state: core.State | None,
- ):
+ def async_entity_state_listener(event: core.Event) -> None:
"""Set expose flag on new entities."""
- if old_state is not None or new_state is None:
- return
- async_should_expose(hass, DOMAIN, changed_entity)
+ async_should_expose(hass, DOMAIN, event.data["entity_id"])
@core.callback
def async_hass_started(hass: core.HomeAssistant) -> None:
"""Set expose flag on all entities."""
for state in hass.states.async_all():
async_should_expose(hass, DOMAIN, state.entity_id)
- async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
+ async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener)
start.async_at_started(hass, async_hass_started)
@@ -284,13 +278,13 @@ class DefaultAgent(AbstractConversationAgent):
all_states = matched + unmatched
domains = {state.domain for state in all_states}
translations = await translation.async_get_translations(
- self.hass, language, "state", domains
+ self.hass, language, "entity_component", domains
)
# Use translated state names
for state in all_states:
device_class = state.attributes.get("device_class", "_")
- key = f"component.{state.domain}.state.{device_class}.{state.state}"
+ key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}"
state.state = translations.get(key, state.state)
# Get first matched or unmatched state.
@@ -419,9 +413,18 @@ class DefaultAgent(AbstractConversationAgent):
encoding="utf-8"
) as custom_sentences_file:
# Merge custom sentences
- merge_dict(
- intents_dict, yaml.safe_load(custom_sentences_file)
- )
+ if isinstance(
+ custom_sentences_yaml := yaml.safe_load(
+ custom_sentences_file
+ ),
+ dict,
+ ):
+ merge_dict(intents_dict, custom_sentences_yaml)
+ else:
+ _LOGGER.warning(
+ "Custom sentences file does not match expected format path=%s",
+ custom_sentences_file.name,
+ )
# Will need to recreate graph
intents_changed = True
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 0221d80002c1..01276d56081a 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -2,10 +2,10 @@
"domain": "conversation",
"name": "Conversation",
"codeowners": ["@home-assistant/core", "@synesthesiam"],
- "dependencies": ["homeassistant", "http"],
+ "dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"]
+ "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.5"]
}
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
index 768491f60852..d2834b8991b7 100644
--- a/homeassistant/components/counter/__init__.py
+++ b/homeassistant/components/counter/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
@@ -44,6 +45,7 @@ SERVICE_DECREMENT = "decrement"
SERVICE_INCREMENT = "increment"
SERVICE_RESET = "reset"
SERVICE_CONFIGURE = "configure"
+SERVICE_SET_VALUE = "set_value"
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
@@ -124,6 +126,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment")
component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement")
component.async_register_entity_service(SERVICE_RESET, {}, "async_reset")
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE,
+ {vol.Required(VALUE): cv.positive_int},
+ "async_set_value",
+ )
component.async_register_entity_service(
SERVICE_CONFIGURE,
{
@@ -238,10 +245,6 @@ class Counter(collection.CollectionEntity, RestoreEntity):
and (state := await self.async_get_last_state()) is not None
):
self._state = self.compute_next_state(int(state.state))
- self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL)
- self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM)
- self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM)
- self._config[CONF_STEP] = state.attributes.get(ATTR_STEP)
@callback
def async_decrement(self) -> None:
@@ -261,9 +264,41 @@ class Counter(collection.CollectionEntity, RestoreEntity):
self._state = self.compute_next_state(self._config[CONF_INITIAL])
self.async_write_ha_state()
+ @callback
+ def async_set_value(self, value: int) -> None:
+ """Set counter to value."""
+ if (maximum := self._config.get(CONF_MAXIMUM)) is not None and value > maximum:
+ raise ValueError(
+ f"Value {value} for {self.entity_id} exceeding the maximum value of {maximum}"
+ )
+
+ if (minimum := self._config.get(CONF_MINIMUM)) is not None and value < minimum:
+ raise ValueError(
+ f"Value {value} for {self.entity_id} exceeding the minimum value of {minimum}"
+ )
+
+ if (step := self._config.get(CONF_STEP)) is not None and value % step != 0:
+ raise ValueError(
+ f"Value {value} for {self.entity_id} is not a multiple of the step size {step}"
+ )
+
+ self._state = value
+ self.async_write_ha_state()
+
@callback
def async_configure(self, **kwargs) -> None:
"""Change the counter's settings with a service."""
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ "deprecated_configure_service",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_configure_service",
+ )
+
new_state = kwargs.pop(VALUE, self._state)
self._config = {**self._config, **kwargs}
self._state = self.compute_next_state(new_state)
diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml
index 1930ba0d45bc..835d39c9d2e0 100644
--- a/homeassistant/components/counter/services.yaml
+++ b/homeassistant/components/counter/services.yaml
@@ -21,48 +21,17 @@ reset:
entity:
domain: counter
-configure:
- name: Configure
- description: Change counter parameters.
+set_value:
+ name: Set
+ description: Set the counter value
target:
entity:
domain: counter
fields:
- minimum:
- name: Minimum
- description: New minimum value for the counter or None to remove minimum.
- selector:
- number:
- min: -9223372036854775807
- max: 9223372036854775807
- mode: box
- maximum:
- name: Maximum
- description: New maximum value for the counter or None to remove maximum.
- selector:
- number:
- min: -9223372036854775807
- max: 9223372036854775807
- mode: box
- step:
- name: Step
- description: New value for step.
- selector:
- number:
- min: 1
- max: 9223372036854775807
- mode: box
- initial:
- name: Initial
- description: New value for initial.
- selector:
- number:
- min: 0
- max: 9223372036854775807
- mode: box
value:
name: Value
- description: New state value.
+ required: true
+ description: The new counter value the entity should be set to.
selector:
number:
min: 0
diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json
index 548d1554080d..095925946593 100644
--- a/homeassistant/components/counter/strings.json
+++ b/homeassistant/components/counter/strings.json
@@ -25,5 +25,18 @@
}
}
}
+ },
+ "issues": {
+ "deprecated_configure_service": {
+ "title": "The counter configure service is being removed",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "The counter configure service is being removed",
+ "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue."
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json
index 2f61bd95083e..663df02a8240 100644
--- a/homeassistant/components/cover/strings.json
+++ b/homeassistant/components/cover/strings.json
@@ -76,5 +76,11 @@
"window": {
"name": "Window"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index 6f90b0cf5efa..02a8cdbe68fe 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -7,6 +7,6 @@
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"quality_scale": "platinum",
- "requirements": ["pydaikin==2.9.0"],
+ "requirements": ["pydaikin==2.9.1"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py
index 68cd4fdc590d..1b83f7f7330f 100644
--- a/homeassistant/components/daikin/switch.py
+++ b/homeassistant/components/daikin/switch.py
@@ -42,7 +42,7 @@ async def async_setup_entry(
[
DaikinZoneSwitch(daikin_api, zone_id)
for zone_id, zone in enumerate(zones)
- if zone != ("-", "0")
+ if zone[0] != ("-", "0")
]
)
if daikin_api.device.support_advanced_modes:
@@ -90,11 +90,11 @@ class DaikinZoneSwitch(SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the zone on."""
- await self._api.device.set_zone(self._zone_id, "1")
+ await self._api.device.set_zone(self._zone_id, "zone_onoff", "1")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the zone off."""
- await self._api.device.set_zone(self._zone_id, "0")
+ await self._api.device.set_zone(self._zone_id, "zone_onoff", "0")
class DaikinStreamerSwitch(SwitchEntity):
diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py
new file mode 100644
index 000000000000..51f3a492c47a
--- /dev/null
+++ b/homeassistant/components/date/__init__.py
@@ -0,0 +1,109 @@
+"""Component to allow setting date as platforms."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, timedelta
+import logging
+from typing import final
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_DATE
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa: F401
+ PLATFORM_SCHEMA,
+ PLATFORM_SCHEMA_BASE,
+)
+from homeassistant.helpers.entity import Entity, EntityDescription
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, SERVICE_SET_VALUE
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+ENTITY_ID_FORMAT = DOMAIN + ".{}"
+
+_LOGGER = logging.getLogger(__name__)
+
+__all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"]
+
+
+async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None:
+ """Service call wrapper to set a new date."""
+ return await entity.async_set_value(service_call.data[ATTR_DATE])
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Date entities."""
+ component = hass.data[DOMAIN] = EntityComponent[DateEntity](
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL
+ )
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE, {vol.Required(ATTR_DATE): cv.date}, _async_set_value
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a config entry."""
+ component: EntityComponent[DateEntity] = hass.data[DOMAIN]
+ return await component.async_setup_entry(entry)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ component: EntityComponent[DateEntity] = hass.data[DOMAIN]
+ return await component.async_unload_entry(entry)
+
+
+@dataclass
+class DateEntityDescription(EntityDescription):
+ """A class that describes date entities."""
+
+
+class DateEntity(Entity):
+ """Representation of a Date entity."""
+
+ entity_description: DateEntityDescription
+ _attr_device_class: None
+ _attr_native_value: date | None
+ _attr_state: None = None
+
+ @property
+ @final
+ def device_class(self) -> None:
+ """Return the device class for the entity."""
+ return None
+
+ @property
+ @final
+ def state_attributes(self) -> None:
+ """Return the state attributes."""
+ return None
+
+ @property
+ @final
+ def state(self) -> str | None:
+ """Return the entity state."""
+ if self.native_value is None:
+ return None
+ return self.native_value.isoformat()
+
+ @property
+ def native_value(self) -> date | None:
+ """Return the value reported by the date."""
+ return self._attr_native_value
+
+ def set_value(self, value: date) -> None:
+ """Change the date."""
+ raise NotImplementedError()
+
+ async def async_set_value(self, value: date) -> None:
+ """Change the date."""
+ await self.hass.async_add_executor_job(self.set_value, value)
diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py
new file mode 100644
index 000000000000..aa87b330e033
--- /dev/null
+++ b/homeassistant/components/date/const.py
@@ -0,0 +1,5 @@
+"""Provides the constants needed for the component."""
+
+DOMAIN = "date"
+
+SERVICE_SET_VALUE = "set_value"
diff --git a/homeassistant/components/date/manifest.json b/homeassistant/components/date/manifest.json
new file mode 100644
index 000000000000..f0e51390ebfc
--- /dev/null
+++ b/homeassistant/components/date/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "date",
+ "name": "Date",
+ "codeowners": ["@home-assistant/core"],
+ "documentation": "https://www.home-assistant.io/integrations/date",
+ "integration_type": "entity",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/date/services.yaml b/homeassistant/components/date/services.yaml
new file mode 100644
index 000000000000..7ce1210f8092
--- /dev/null
+++ b/homeassistant/components/date/services.yaml
@@ -0,0 +1,14 @@
+set_value:
+ name: Set Date
+ description: Set the date for a date entity.
+ target:
+ entity:
+ domain: date
+ fields:
+ date:
+ name: Date
+ description: The date to set.
+ required: true
+ example: "2022/11/01"
+ selector:
+ date:
diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json
new file mode 100644
index 000000000000..f2d2e5ef8e18
--- /dev/null
+++ b/homeassistant/components/date/strings.json
@@ -0,0 +1,14 @@
+{
+ "title": "Date",
+ "entity_component": {
+ "_": {
+ "name": "[%key:component::date::title%]"
+ }
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
+ }
+}
diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py
new file mode 100644
index 000000000000..fb67f4b1ffb6
--- /dev/null
+++ b/homeassistant/components/datetime/__init__.py
@@ -0,0 +1,126 @@
+"""Component to allow setting date/time as platforms."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+import logging
+from typing import final
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa: F401
+ ENTITY_SERVICE_FIELDS,
+ PLATFORM_SCHEMA,
+ PLATFORM_SCHEMA_BASE,
+)
+from homeassistant.helpers.entity import Entity, EntityDescription
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
+
+from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+ENTITY_ID_FORMAT = DOMAIN + ".{}"
+
+_LOGGER = logging.getLogger(__name__)
+
+__all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"]
+
+
+async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None:
+ """Service call wrapper to set a new date/time."""
+ value: datetime = service_call.data[ATTR_DATETIME]
+ if value.tzinfo is None:
+ value = value.replace(
+ tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone)
+ )
+ return await entity.async_set_value(value)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Date/Time entities."""
+ component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity](
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL
+ )
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE,
+ {
+ vol.Required(ATTR_DATETIME): cv.datetime,
+ **ENTITY_SERVICE_FIELDS,
+ },
+ _async_set_value,
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a config entry."""
+ component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN]
+ return await component.async_setup_entry(entry)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN]
+ return await component.async_unload_entry(entry)
+
+
+@dataclass
+class DateTimeEntityDescription(EntityDescription):
+ """A class that describes date/time entities."""
+
+
+class DateTimeEntity(Entity):
+ """Representation of a Date/time entity."""
+
+ entity_description: DateTimeEntityDescription
+ _attr_device_class: None = None
+ _attr_state: None = None
+ _attr_native_value: datetime | None
+
+ @property
+ @final
+ def device_class(self) -> None:
+ """Return entity device class."""
+ return None
+
+ @property
+ @final
+ def state_attributes(self) -> None:
+ """Return the state attributes."""
+ return None
+
+ @property
+ @final
+ def state(self) -> str | None:
+ """Return the entity state."""
+ if (value := self.native_value) is None:
+ return None
+ if value.tzinfo is None:
+ raise ValueError(
+ f"Invalid datetime: {self.entity_id} provides state '{value}', "
+ "which is missing timezone information"
+ )
+
+ return value.astimezone(timezone.utc).isoformat(timespec="seconds")
+
+ @property
+ def native_value(self) -> datetime | None:
+ """Return the value reported by the datetime."""
+ return self._attr_native_value
+
+ def set_value(self, value: datetime) -> None:
+ """Change the date/time."""
+ raise NotImplementedError()
+
+ async def async_set_value(self, value: datetime) -> None:
+ """Change the date/time."""
+ await self.hass.async_add_executor_job(self.set_value, value)
diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py
new file mode 100644
index 000000000000..f9a5c4e538cf
--- /dev/null
+++ b/homeassistant/components/datetime/const.py
@@ -0,0 +1,7 @@
+"""Provides the constants needed for the component."""
+
+DOMAIN = "datetime"
+
+ATTR_DATETIME = "datetime"
+
+SERVICE_SET_VALUE = "set_value"
diff --git a/homeassistant/components/datetime/manifest.json b/homeassistant/components/datetime/manifest.json
new file mode 100644
index 000000000000..469d9a8bd988
--- /dev/null
+++ b/homeassistant/components/datetime/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "datetime",
+ "name": "Date/Time",
+ "codeowners": ["@home-assistant/core"],
+ "documentation": "https://www.home-assistant.io/integrations/datetime",
+ "integration_type": "entity",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml
new file mode 100644
index 000000000000..b5cce19e88b7
--- /dev/null
+++ b/homeassistant/components/datetime/services.yaml
@@ -0,0 +1,14 @@
+set_value:
+ name: Set Date/Time
+ description: Set the date/time for a datetime entity.
+ target:
+ entity:
+ domain: datetime
+ fields:
+ datetime:
+ name: Date & Time
+ description: The date/time to set. The time zone of the Home Assistant instance is assumed.
+ required: true
+ example: "2022/11/01 22:15"
+ selector:
+ datetime:
diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json
new file mode 100644
index 000000000000..3b97559018c7
--- /dev/null
+++ b/homeassistant/components/datetime/strings.json
@@ -0,0 +1,8 @@
+{
+ "title": "Date/Time",
+ "entity_component": {
+ "_": {
+ "name": "[%key:component::datetime::title%]"
+ }
+ }
+}
diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json
index 61794e7c70a2..8139d77df85d 100644
--- a/homeassistant/components/deconz/manifest.json
+++ b/homeassistant/components/deconz/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["pydeconz"],
"quality_scale": "platinum",
- "requirements": ["pydeconz==111"],
+ "requirements": ["pydeconz==112"],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics",
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
index 136f582f5c7f..e5d5de410081 100644
--- a/homeassistant/components/deconz/sensor.py
+++ b/homeassistant/components/deconz/sensor.py
@@ -194,6 +194,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=1,
),
DeconzSensorDescription[LightLevel](
key="light_level",
@@ -234,6 +235,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=1,
),
DeconzSensorDescription[Time](
key="last_set",
diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py
index d91d06949e69..25a9ca311e80 100644
--- a/homeassistant/components/default_config/__init__.py
+++ b/homeassistant/components/default_config/__init__.py
@@ -1,11 +1,14 @@
"""Component providing default configuration for new users."""
from homeassistant.components.hassio import is_hassio
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
DOMAIN = "default_config"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize default configuration."""
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
index 82cb8eff625c..a84d7bf4f0b8 100644
--- a/homeassistant/components/demo/__init__.py
+++ b/homeassistant/components/demo/__init__.py
@@ -14,6 +14,7 @@ from homeassistant.const import (
)
import homeassistant.core as ha
from homeassistant.core import Event, HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
@@ -27,6 +28,8 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
+ Platform.DATE,
+ Platform.DATETIME,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
@@ -39,6 +42,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.STT,
Platform.SWITCH,
Platform.TEXT,
+ Platform.TIME,
Platform.UPDATE,
Platform.VACUUM,
Platform.WATER_HEATER,
@@ -54,6 +58,8 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.DEVICE_TRACKER,
]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment."""
@@ -186,6 +192,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
+async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ await hass.config_entries.async_unload_platforms(
+ config_entry, COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM
+ )
+ return True
+
+
async def finish_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Finish set up once demo platforms are set up."""
switches: list[str] | None = None
diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py
new file mode 100644
index 000000000000..eb96bc490382
--- /dev/null
+++ b/homeassistant/components/demo/date.py
@@ -0,0 +1,73 @@
+"""Demo platform that offers a fake Date entity."""
+from __future__ import annotations
+
+from datetime import date
+
+from homeassistant.components.date import DateEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+
+from . import DOMAIN
+
+
+async def async_setup_platform(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_add_entities: AddEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the Demo date entity."""
+ async_add_entities(
+ [
+ DemoDate(
+ "date",
+ "Date",
+ date(2020, 1, 1),
+ "mdi:calendar",
+ False,
+ ),
+ ]
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Demo config entry."""
+ await async_setup_platform(hass, {}, async_add_entities)
+
+
+class DemoDate(DateEntity):
+ """Representation of a Demo date entity."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ unique_id: str,
+ name: str,
+ state: date,
+ icon: str,
+ assumed_state: bool,
+ ) -> None:
+ """Initialize the Demo date entity."""
+ self._attr_assumed_state = assumed_state
+ self._attr_icon = icon
+ self._attr_name = name or DEVICE_DEFAULT_NAME
+ self._attr_native_value = state
+ self._attr_unique_id = unique_id
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)}, name=self.name
+ )
+
+ async def async_set_value(self, value: date) -> None:
+ """Update the date."""
+ self._attr_native_value = value
+ self.async_write_ha_state()
diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py
new file mode 100644
index 000000000000..88027f58b922
--- /dev/null
+++ b/homeassistant/components/demo/datetime.py
@@ -0,0 +1,77 @@
+"""Demo platform that offers a fake date/time entity."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from homeassistant.components.datetime import DateTimeEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+
+from . import DOMAIN
+
+
+async def async_setup_platform(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_add_entities: AddEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the Demo date/time entity."""
+ async_add_entities(
+ [
+ DemoDateTime(
+ "datetime",
+ "Date and Time",
+ datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ "mdi:calendar-clock",
+ False,
+ ),
+ ]
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Demo config entry."""
+ await async_setup_platform(hass, {}, async_add_entities)
+
+
+class DemoDateTime(DateTimeEntity):
+ """Representation of a Demo date/time entity."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ unique_id: str,
+ name: str,
+ state: datetime,
+ icon: str,
+ assumed_state: bool,
+ ) -> None:
+ """Initialize the Demo date/time entity."""
+ self._attr_assumed_state = assumed_state
+ self._attr_icon = icon
+ self._attr_name = name or DEVICE_DEFAULT_NAME
+ self._attr_native_value = state
+ self._attr_unique_id = unique_id
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ # Serial numbers are unique identifiers within a specific domain
+ (DOMAIN, unique_id)
+ },
+ name=self.name,
+ )
+
+ async def async_set_value(self, value: datetime) -> None:
+ """Update the date/time."""
+ self._attr_native_value = value
+ self.async_write_ha_state()
diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py
index 2af7437e0f68..cd020d1bb8a1 100644
--- a/homeassistant/components/demo/geo_location.py
+++ b/homeassistant/components/demo/geo_location.py
@@ -86,7 +86,10 @@ class DemoManager:
def _init_regular_updates(self) -> None:
"""Schedule regular updates based on configured time interval."""
track_time_interval(
- self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL
+ self._hass,
+ lambda now: self._update(),
+ DEFAULT_UPDATE_INTERVAL,
+ cancel_on_shutdown=True,
)
def _update(self, count: int = 1) -> None:
diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py
index 6f0b23525e59..9627383443eb 100644
--- a/homeassistant/components/demo/mailbox.py
+++ b/homeassistant/components/demo/mailbox.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -34,7 +34,9 @@ class DemoMailbox(Mailbox):
self._messages: dict[str, dict[str, Any]] = {}
txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
for idx in range(0, 10):
- msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx))
+ msgtime = int(
+ dt_util.as_timestamp(dt_util.utcnow()) - 3600 * 24 * (10 - idx)
+ )
msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}"
msgsha = sha1(msgtxt.encode("utf-8")).hexdigest()
msg = {
diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py
index 07a844c048cc..6458bf473975 100644
--- a/homeassistant/components/demo/stt.py
+++ b/homeassistant/components/demo/stt.py
@@ -1,4 +1,4 @@
-"""Support for the demo for speech to text service."""
+"""Support for the demo for speech-to-text service."""
from __future__ import annotations
from collections.abc import AsyncIterable
diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py
new file mode 100644
index 000000000000..aafd425a024f
--- /dev/null
+++ b/homeassistant/components/demo/time.py
@@ -0,0 +1,63 @@
+"""Demo platform that offers a fake time entity."""
+from __future__ import annotations
+
+from datetime import time
+
+from homeassistant.components.time import TimeEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+
+from . import DOMAIN
+
+
+async def async_setup_platform(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_add_entities: AddEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the Demo time entity."""
+ async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)])
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Demo config entry."""
+ await async_setup_platform(hass, {}, async_add_entities)
+
+
+class DemoTime(TimeEntity):
+ """Representation of a Demo time entity."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ unique_id: str,
+ name: str,
+ state: time,
+ icon: str,
+ assumed_state: bool,
+ ) -> None:
+ """Initialize the Demo time entity."""
+ self._attr_assumed_state = assumed_state
+ self._attr_icon = icon
+ self._attr_name = name or DEVICE_DEFAULT_NAME
+ self._attr_native_value = state
+ self._attr_unique_id = unique_id
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)}, name=self.name
+ )
+
+ async def async_set_value(self, value: time) -> None:
+ """Update the time."""
+ self._attr_native_value = value
+ self.async_write_ha_state()
diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py
index 2c9cd654d842..dfc8d7d7efb1 100644
--- a/homeassistant/components/demo/tts.py
+++ b/homeassistant/components/demo/tts.py
@@ -1,4 +1,4 @@
-"""Support for the demo for text to speech service."""
+"""Support for the demo for text-to-speech service."""
from __future__ import annotations
import os
@@ -57,7 +57,7 @@ class DemoProvider(Provider):
return ["voice", "age"]
def get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load TTS from demo."""
filename = os.path.join(os.path.dirname(__file__), "tts.mp3")
diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py
index d5017ac2329e..71acc6dfa79a 100644
--- a/homeassistant/components/device_automation/__init__.py
+++ b/homeassistant/components/device_automation/__init__.py
@@ -57,6 +57,8 @@ if TYPE_CHECKING:
DOMAIN = "device_automation"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
DEVICE_TRIGGER_BASE_SCHEMA: vol.Schema = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): "device",
diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py
index 5d56548f0ecb..c4450ab60a74 100644
--- a/homeassistant/components/device_tracker/config_entry.py
+++ b/homeassistant/components/device_tracker/config_entry.py
@@ -176,7 +176,9 @@ def _async_register_mac(
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
- hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
+ hass.bus.async_listen(
+ dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event, run_immediately=True
+ )
class BaseTrackerEntity(Entity):
diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json
index c15b9723c972..54e4f922053a 100644
--- a/homeassistant/components/device_tracker/strings.json
+++ b/homeassistant/components/device_tracker/strings.json
@@ -41,5 +41,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/devolo_home_control/diagnostics.py b/homeassistant/components/devolo_home_control/diagnostics.py
new file mode 100644
index 000000000000..412effcd5ed2
--- /dev/null
+++ b/homeassistant/components/devolo_home_control/diagnostics.py
@@ -0,0 +1,49 @@
+"""Diagnostics support for devolo Home Control."""
+from __future__ import annotations
+
+from typing import Any
+
+from devolo_home_control_api.homecontrol import HomeControl
+
+from homeassistant.components.diagnostics 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
+
+TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ gateways: list[HomeControl] = hass.data[DOMAIN][entry.entry_id]["gateways"]
+
+ device_info = []
+ for gateway in gateways:
+ device_info.append(
+ {
+ "gateway": {
+ "local_connection": gateway.gateway.local_connection,
+ "firmware_version": gateway.gateway.firmware_version,
+ },
+ "devices": [
+ {
+ "device_id": device_id,
+ "device_model_uid": properties.device_model_uid,
+ "device_type": properties.device_type,
+ "name": properties.name,
+ }
+ for device_id, properties in gateway.devices.items()
+ ],
+ }
+ )
+
+ diag_data = {
+ "entry": async_redact_data(entry.as_dict(), TO_REDACT),
+ "device_info": device_info,
+ }
+
+ return diag_data
diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json
index 60f99daffdc5..71ca03f9638b 100644
--- a/homeassistant/components/devolo_home_control/manifest.json
+++ b/homeassistant/components/devolo_home_control/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["devolo_home_control_api"],
- "quality_scale": "silver",
+ "quality_scale": "gold",
"requirements": ["devolo-home-control-api==0.18.2"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py
index 5fdb75bb5f94..00d96ea53b33 100644
--- a/homeassistant/components/devolo_home_network/__init__.py
+++ b/homeassistant/components/devolo_home_network/__init__.py
@@ -20,8 +20,13 @@ from devolo_plc_api.plcnet_api import LogicalNetwork
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import Event, HomeAssistant
+from homeassistant.const import (
+ CONF_IP_ADDRESS,
+ CONF_PASSWORD,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -32,7 +37,6 @@ from .const import (
DOMAIN,
LONG_UPDATE_INTERVAL,
NEIGHBORING_WIFI_NETWORKS,
- PLATFORMS,
SHORT_UPDATE_INTERVAL,
SWITCH_GUEST_WIFI,
SWITCH_LEDS,
@@ -156,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for coordinator in coordinators.values():
await coordinator.async_config_entry_first_refresh()
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(entry, platforms(device))
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
@@ -167,9 +171,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ device: Device = hass.data[DOMAIN][entry.entry_id]["device"]
+ unload_ok = await hass.config_entries.async_unload_platforms(
+ entry, platforms(device)
+ )
if unload_ok:
- await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect()
+ await device.async_disconnect()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+
+
+@callback
+def platforms(device: Device) -> set[Platform]:
+ """Assemble supported platforms."""
+ supported_platforms = {Platform.BUTTON, Platform.SENSOR, Platform.SWITCH}
+ if device.plcnet:
+ supported_platforms.add(Platform.BINARY_SENSOR)
+ if device.device and "wifi1" in device.device.features:
+ supported_platforms.add(Platform.DEVICE_TRACKER)
+ return supported_platforms
diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py
index 809dc9086be5..ebe7e60af7bb 100644
--- a/homeassistant/components/devolo_home_network/binary_sensor.py
+++ b/homeassistant/components/devolo_home_network/binary_sensor.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER, DOMAIN
-from .entity import DevoloEntity
+from .entity import DevoloCoordinatorEntity
def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool:
@@ -68,19 +68,20 @@ async def async_setup_entry(
]["coordinators"]
entities: list[BinarySensorEntity] = []
- if device.plcnet:
- entities.append(
- DevoloBinarySensorEntity(
- entry,
- coordinators[CONNECTED_PLC_DEVICES],
- SENSOR_TYPES[CONNECTED_TO_ROUTER],
- device,
- )
+ entities.append(
+ DevoloBinarySensorEntity(
+ entry,
+ coordinators[CONNECTED_PLC_DEVICES],
+ SENSOR_TYPES[CONNECTED_TO_ROUTER],
+ device,
)
+ )
async_add_entities(entities)
-class DevoloBinarySensorEntity(DevoloEntity[LogicalNetwork], BinarySensorEntity):
+class DevoloBinarySensorEntity(
+ DevoloCoordinatorEntity[LogicalNetwork], BinarySensorEntity
+):
"""Representation of a devolo binary sensor."""
def __init__(
diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py
new file mode 100644
index 000000000000..463356268a64
--- /dev/null
+++ b/homeassistant/components/devolo_home_network/button.py
@@ -0,0 +1,133 @@
+"""Platform for button integration."""
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from devolo_plc_api.device import Device
+from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
+from .entity import DevoloEntity
+
+
+@dataclass
+class DevoloButtonRequiredKeysMixin:
+ """Mixin for required keys."""
+
+ press_func: Callable[[Device], Awaitable[bool]]
+
+
+@dataclass
+class DevoloButtonEntityDescription(
+ ButtonEntityDescription, DevoloButtonRequiredKeysMixin
+):
+ """Describes devolo button entity."""
+
+
+BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = {
+ IDENTIFY: DevoloButtonEntityDescription(
+ key=IDENTIFY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ icon="mdi:led-on",
+ press_func=lambda device: device.plcnet.async_identify_device_start(), # type: ignore[union-attr]
+ ),
+ PAIRING: DevoloButtonEntityDescription(
+ key=PAIRING,
+ icon="mdi:plus-network-outline",
+ press_func=lambda device: device.plcnet.async_pair_device(), # type: ignore[union-attr]
+ ),
+ RESTART: DevoloButtonEntityDescription(
+ key=RESTART,
+ device_class=ButtonDeviceClass.RESTART,
+ entity_category=EntityCategory.CONFIG,
+ press_func=lambda device: device.device.async_restart(), # type: ignore[union-attr]
+ ),
+ START_WPS: DevoloButtonEntityDescription(
+ key=START_WPS,
+ icon="mdi:wifi-plus",
+ press_func=lambda device: device.device.async_start_wps(), # type: ignore[union-attr]
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Get all devices and buttons and setup them via config entry."""
+ device: Device = hass.data[DOMAIN][entry.entry_id]["device"]
+
+ entities: list[DevoloButtonEntity] = []
+ if device.plcnet:
+ entities.append(
+ DevoloButtonEntity(
+ entry,
+ BUTTON_TYPES[IDENTIFY],
+ device,
+ )
+ )
+ entities.append(
+ DevoloButtonEntity(
+ entry,
+ BUTTON_TYPES[PAIRING],
+ device,
+ )
+ )
+ if device.device and "restart" in device.device.features:
+ entities.append(
+ DevoloButtonEntity(
+ entry,
+ BUTTON_TYPES[RESTART],
+ device,
+ )
+ )
+ if device.device and "wifi1" in device.device.features:
+ entities.append(
+ DevoloButtonEntity(
+ entry,
+ BUTTON_TYPES[START_WPS],
+ device,
+ )
+ )
+ async_add_entities(entities)
+
+
+class DevoloButtonEntity(DevoloEntity, ButtonEntity):
+ """Representation of a devolo button."""
+
+ entity_description: DevoloButtonEntityDescription
+
+ def __init__(
+ self,
+ entry: ConfigEntry,
+ description: DevoloButtonEntityDescription,
+ device: Device,
+ ) -> None:
+ """Initialize entity."""
+ self.entity_description = description
+ super().__init__(entry, device)
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.entity_description.press_func(self.device)
+ except DevicePasswordProtected as ex:
+ self.entry.async_start_reauth(self.hass)
+ raise HomeAssistantError(
+ f"Device {self.entry.title} require re-authenticatication to set or change the password"
+ ) from ex
+ except DeviceUnavailable as ex:
+ raise HomeAssistantError(
+ f"Device {self.entry.title} did not respond"
+ ) from ex
diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py
index fffe9b5d482e..39016ac79166 100644
--- a/homeassistant/components/devolo_home_network/const.py
+++ b/homeassistant/components/devolo_home_network/const.py
@@ -9,16 +9,7 @@ from devolo_plc_api.device_api import (
WIFI_VAP_MAIN_AP,
)
-from homeassistant.const import Platform
-
DOMAIN = "devolo_home_network"
-PLATFORMS = [
- Platform.BINARY_SENSOR,
- Platform.DEVICE_TRACKER,
- Platform.SENSOR,
- Platform.SWITCH,
-]
-
PRODUCT = "product"
SERIAL_NUMBER = "serial_number"
TITLE = "title"
@@ -29,7 +20,11 @@ SHORT_UPDATE_INTERVAL = timedelta(seconds=15)
CONNECTED_PLC_DEVICES = "connected_plc_devices"
CONNECTED_TO_ROUTER = "connected_to_router"
CONNECTED_WIFI_CLIENTS = "connected_wifi_clients"
+IDENTIFY = "identify"
NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks"
+PAIRING = "pairing"
+RESTART = "restart"
+START_WPS = "start_wps"
SWITCH_GUEST_WIFI = "switch_guest_wifi"
SWITCH_LEDS = "switch_leds"
diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py
index eb6e9cf6ec6e..c73e08abed20 100644
--- a/homeassistant/components/devolo_home_network/device_tracker.py
+++ b/homeassistant/components/devolo_home_network/device_tracker.py
@@ -73,11 +73,10 @@ async def async_setup_entry(
async_add_entities(missing)
- if device.device and "wifi1" in device.device.features:
- restore_entities()
- entry.async_on_unload(
- coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback)
- )
+ restore_entities()
+ entry.async_on_unload(
+ coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback)
+ )
class DevoloScannerEntity(
diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py
index 8b665d7bf024..e477df63bd22 100644
--- a/homeassistant/components/devolo_home_network/entity.py
+++ b/homeassistant/components/devolo_home_network/entity.py
@@ -12,7 +12,7 @@ from devolo_plc_api.device_api import (
from devolo_plc_api.plcnet_api import LogicalNetwork
from homeassistant.config_entries import ConfigEntry
-from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -32,7 +32,7 @@ _DataT = TypeVar(
)
-class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]):
+class DevoloEntity(Entity):
"""Representation of a devolo home network device."""
_attr_has_entity_name = True
@@ -40,12 +40,9 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]):
def __init__(
self,
entry: ConfigEntry,
- coordinator: DataUpdateCoordinator[_DataT],
device: Device,
) -> None:
"""Initialize a devolo home network device."""
- super().__init__(coordinator)
-
self.device = device
self.entry = entry
@@ -59,3 +56,19 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]):
)
self._attr_translation_key = self.entity_description.key
self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}"
+
+
+class DevoloCoordinatorEntity(
+ CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity
+):
+ """Representation of a coordinated devolo home network device."""
+
+ def __init__(
+ self,
+ entry: ConfigEntry,
+ coordinator: DataUpdateCoordinator[_DataT],
+ device: Device,
+ ) -> None:
+ """Initialize a devolo home network device."""
+ super().__init__(coordinator)
+ DevoloEntity.__init__(self, entry, device)
diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json
index e3aaf2beaf97..e635b1f7021c 100644
--- a/homeassistant/components/devolo_home_network/manifest.json
+++ b/homeassistant/components/devolo_home_network/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["devolo_plc_api"],
"quality_scale": "platinum",
- "requirements": ["devolo-plc-api==1.2.0"],
+ "requirements": ["devolo-plc-api==1.3.1"],
"zeroconf": [
{
"type": "_dvl-deviceapi._tcp.local.",
diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py
index aeeab2ce89b0..7a6da1f41a57 100644
--- a/homeassistant/components/devolo_home_network/sensor.py
+++ b/homeassistant/components/devolo_home_network/sensor.py
@@ -26,7 +26,7 @@ from .const import (
DOMAIN,
NEIGHBORING_WIFI_NETWORKS,
)
-from .entity import DevoloEntity
+from .entity import DevoloCoordinatorEntity
_DataT = TypeVar(
"_DataT",
@@ -113,7 +113,7 @@ async def async_setup_entry(
async_add_entities(entities)
-class DevoloSensorEntity(DevoloEntity[_DataT], SensorEntity):
+class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity):
"""Representation of a devolo sensor."""
entity_description: DevoloSensorEntityDescription[_DataT]
diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json
index 3472886cd5b0..e2954c1c7ecc 100644
--- a/homeassistant/components/devolo_home_network/strings.json
+++ b/homeassistant/components/devolo_home_network/strings.json
@@ -34,6 +34,20 @@
"name": "Connected to router"
}
},
+ "button": {
+ "identify": {
+ "name": "Identify device with a blinking LED"
+ },
+ "pairing": {
+ "name": "Start PLC pairing"
+ },
+ "restart": {
+ "name": "Restart device"
+ },
+ "start_wps": {
+ "name": "Start WPS"
+ }
+ },
"sensor": {
"connected_plc_devices": {
"name": "Connected PLC devices"
diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py
index 6f387fdf05f4..e7bcee3f2ece 100644
--- a/homeassistant/components/devolo_home_network/switch.py
+++ b/homeassistant/components/devolo_home_network/switch.py
@@ -13,11 +13,12 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
-from .entity import DevoloEntity
+from .entity import DevoloCoordinatorEntity
_DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool)
@@ -88,7 +89,7 @@ async def async_setup_entry(
async_add_entities(entities)
-class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity):
+class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity):
"""Representation of a devolo switch."""
entity_description: DevoloSwitchEntityDescription[_DataT]
@@ -113,8 +114,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity):
"""Turn the entity on."""
try:
await self.entity_description.turn_on_func(self.device)
- except DevicePasswordProtected:
+ except DevicePasswordProtected as ex:
self.entry.async_start_reauth(self.hass)
+ raise HomeAssistantError(
+ f"Device {self.entry.title} require re-authenticatication to set or change the password"
+ ) from ex
except DeviceUnavailable:
pass # The coordinator will handle this
await self.coordinator.async_request_refresh()
@@ -123,8 +127,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity):
"""Turn the entity off."""
try:
await self.entity_description.turn_off_func(self.device)
- except DevicePasswordProtected:
+ except DevicePasswordProtected as ex:
self.entry.async_start_reauth(self.hass)
+ raise HomeAssistantError(
+ f"Device {self.entry.title} require re-authenticatication to set or change the password"
+ ) from ex
except DeviceUnavailable:
pass # The coordinator will handle this
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py
index 7f41d2c1d3d3..4a9f6c2b163d 100644
--- a/homeassistant/components/dhcp/__init__.py
+++ b/homeassistant/components/dhcp/__init__.py
@@ -42,7 +42,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.data_entry_flow import BaseServiceInfo
-from homeassistant.helpers import discovery_flow
+from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceRegistry,
@@ -59,10 +59,14 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.network import is_invalid, is_link_local, is_loopback
+from .const import DOMAIN
+
if TYPE_CHECKING:
from scapy.packet import Packet
from scapy.sendrecv import AsyncSniffer
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
FILTER = "udp and (port 67 or 68)"
REQUESTED_ADDR = "requested_addr"
MESSAGE_TYPE = "message-type"
diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py
index ea7b13f47191..2ff220b90966 100644
--- a/homeassistant/components/diagnostics/__init__.py
+++ b/homeassistant/components/diagnostics/__init__.py
@@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant.components import http, websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import integration_platform
+from homeassistant.helpers import config_validation as cv, integration_platform
from homeassistant.helpers.device_registry import DeviceEntry, async_get
from homeassistant.helpers.json import (
ExtendedJSONEncoder,
@@ -33,7 +33,10 @@ __all__ = ["REDACTED", "async_redact_data"]
_LOGGER = logging.getLogger(__name__)
-@dataclass
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+@dataclass(slots=True)
class DiagnosticsPlatformData:
"""Diagnostic platform data."""
@@ -46,7 +49,7 @@ class DiagnosticsPlatformData:
] | None
-@dataclass
+@dataclass(slots=True)
class DiagnosticsData:
"""Diagnostic data."""
diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py
index fee99898ccc1..7e62869c3fa7 100644
--- a/homeassistant/components/dialogflow/config_flow.py
+++ b/homeassistant/components/dialogflow/config_flow.py
@@ -7,7 +7,7 @@ config_entry_flow.register_webhook_flow(
DOMAIN,
"Dialogflow Webhook",
{
- "dialogflow_url": "https://dialogflow.com/docs/fulfillment#webhook",
+ "dialogflow_url": "https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook",
"docs_url": "https://www.home-assistant.io/integrations/dialogflow/",
},
)
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
index 21b25962fce1..8c1570db1596 100644
--- a/homeassistant/components/directv/media_player.py
+++ b/homeassistant/components/directv/media_player.py
@@ -112,7 +112,8 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity):
self._paused = self._last_position == self._program.position
self._is_recorded = self._program.recorded
self._last_position = self._program.position
- self._last_update = state.at
+ if not self._paused:
+ self._last_update = dt_util.utcnow()
self._attr_assumed_state = self._is_recorded
@property
diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py
index 4499e2efffcc..09df5571a787 100644
--- a/homeassistant/components/dlink/config_flow.py
+++ b/homeassistant/components/dlink/config_flow.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import dhcp
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN
@@ -72,15 +72,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
- """Import a config entry."""
- self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]})
- title = config.pop(CONF_NAME, DEFAULT_NAME)
- return self.async_create_entry(
- title=title,
- data=config,
- )
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py
index 33302f7fffac..bfe16abd7809 100644
--- a/homeassistant/components/dlink/entity.py
+++ b/homeassistant/components/dlink/entity.py
@@ -1,7 +1,7 @@
"""Entity representing a D-Link Power Plug device."""
from __future__ import annotations
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
@@ -14,6 +14,7 @@ class DLinkEntity(Entity):
"""Representation of a D-Link Power Plug entity."""
_attr_attribution = ATTRIBUTION
+ _attr_has_entity_name = True
def __init__(
self,
@@ -24,10 +25,6 @@ class DLinkEntity(Entity):
"""Initialize a D-Link Power Plug entity."""
self.data = data
self.entity_description = description
- if config_entry.source == SOURCE_IMPORT:
- self._attr_name = config_entry.title
- else:
- self._attr_has_entity_name = True
self._attr_unique_id = f"{config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py
index e6b9a4c7883e..d06372bb28b3 100644
--- a/homeassistant/components/dlink/switch.py
+++ b/homeassistant/components/dlink/switch.py
@@ -4,80 +4,23 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any
-import voluptuous as vol
-
-from homeassistant.components.switch import (
- PLATFORM_SCHEMA,
- SwitchEntity,
- SwitchEntityDescription,
-)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- ATTR_TEMPERATURE,
- CONF_HOST,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_USERNAME,
- UnitOfTemperature,
-)
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import (
- ATTR_TOTAL_CONSUMPTION,
- CONF_USE_LEGACY_PROTOCOL,
- DEFAULT_NAME,
- DEFAULT_USERNAME,
- DOMAIN,
-)
+from .const import ATTR_TOTAL_CONSUMPTION, DOMAIN
from .entity import DLinkEntity
SCAN_INTERVAL = timedelta(minutes=2)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PASSWORD, default=""): cv.string,
- vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean,
- }
-)
-
SWITCH_TYPE = SwitchEntityDescription(
key="switch",
name="Switch",
)
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a D-Link Smart Plug."""
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2023.4.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- )
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- )
-
-
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index 15c075454fc5..524f5c4ffc2a 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -80,7 +80,7 @@ class DSMRSensorEntityDescription(
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="current_electricity_usage",
- name="Power consumption",
+ translation_key="current_electricity_usage",
obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE,
device_class=SensorDeviceClass.POWER,
force_update=True,
@@ -88,7 +88,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="current_electricity_delivery",
- name="Power production",
+ translation_key="current_electricity_delivery",
obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY,
device_class=SensorDeviceClass.POWER,
force_update=True,
@@ -96,17 +96,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_active_tariff",
- name="Active tariff",
+ translation_key="electricity_active_tariff",
obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
- translation_key="electricity_tariff",
icon="mdi:flash",
),
DSMRSensorEntityDescription(
key="electricity_used_tariff_1",
- name="Energy consumption (tarif 1)",
+ translation_key="electricity_used_tariff_1",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
device_class=SensorDeviceClass.ENERGY,
@@ -115,7 +114,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_used_tariff_2",
- name="Energy consumption (tarif 2)",
+ translation_key="electricity_used_tariff_2",
obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
force_update=True,
@@ -124,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_1",
- name="Energy production (tarif 1)",
+ translation_key="electricity_delivered_tariff_1",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
force_update=True,
@@ -133,7 +132,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_delivered_tariff_2",
- name="Energy production (tarif 2)",
+ translation_key="electricity_delivered_tariff_2",
obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
force_update=True,
@@ -142,7 +141,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_positive",
- name="Power consumption phase L1",
+ translation_key="instantaneous_active_power_l1_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -150,7 +149,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_positive",
- name="Power consumption phase L2",
+ translation_key="instantaneous_active_power_l2_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -158,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_positive",
- name="Power consumption phase L3",
+ translation_key="instantaneous_active_power_l3_positive",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -166,7 +165,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l1_negative",
- name="Power production phase L1",
+ translation_key="instantaneous_active_power_l1_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -174,7 +173,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l2_negative",
- name="Power production phase L2",
+ translation_key="instantaneous_active_power_l2_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -182,7 +181,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_active_power_l3_negative",
- name="Power production phase L3",
+ translation_key="instantaneous_active_power_l3_negative",
obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
@@ -190,7 +189,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="short_power_failure_count",
- name="Short power failure count",
+ translation_key="short_power_failure_count",
obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="long_power_failure_count",
- name="Long power failure count",
+ translation_key="long_power_failure_count",
obis_reference=obis_references.LONG_POWER_FAILURE_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -208,7 +207,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_sag_l1_count",
- name="Voltage sags phase L1",
+ translation_key="voltage_sag_l1_count",
obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -216,7 +215,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_sag_l2_count",
- name="Voltage sags phase L2",
+ translation_key="voltage_sag_l2_count",
obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -224,7 +223,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_sag_l3_count",
- name="Voltage sags phase L3",
+ translation_key="voltage_sag_l3_count",
obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -232,7 +231,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_swell_l1_count",
- name="Voltage swells phase L1",
+ translation_key="voltage_swell_l1_count",
obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -241,7 +240,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_swell_l2_count",
- name="Voltage swells phase L2",
+ translation_key="voltage_swell_l2_count",
obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -250,7 +249,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="voltage_swell_l3_count",
- name="Voltage swells phase L3",
+ translation_key="voltage_swell_l3_count",
obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT,
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
entity_registry_enabled_default=False,
@@ -259,7 +258,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l1",
- name="Voltage phase L1",
+ translation_key="instantaneous_voltage_l1",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
@@ -268,7 +267,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l2",
- name="Voltage phase L2",
+ translation_key="instantaneous_voltage_l2",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
@@ -277,7 +276,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_voltage_l3",
- name="Voltage phase L3",
+ translation_key="instantaneous_voltage_l3",
obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
@@ -286,7 +285,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_current_l1",
- name="Current phase L1",
+ translation_key="instantaneous_current_l1",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
@@ -295,7 +294,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_current_l2",
- name="Current phase L2",
+ translation_key="instantaneous_current_l2",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
@@ -304,7 +303,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="instantaneous_current_l3",
- name="Current phase L3",
+ translation_key="instantaneous_current_l3",
obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
@@ -313,7 +312,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="belgium_max_power_per_phase",
- name="Max power per phase",
+ translation_key="max_power_per_phase",
obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
@@ -323,7 +322,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="belgium_max_current_per_phase",
- name="Max current per phase",
+ translation_key="max_current_per_phase",
obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE,
dsmr_versions={"5B"},
device_class=SensorDeviceClass.POWER,
@@ -333,7 +332,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_imported_total",
- name="Energy consumption (total)",
+ translation_key="electricity_imported_total",
obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL,
dsmr_versions={"5L", "5S", "Q3D"},
force_update=True,
@@ -342,7 +341,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="electricity_exported_total",
- name="Energy production (total)",
+ translation_key="electricity_exported_total",
obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL,
dsmr_versions={"5L", "5S", "Q3D"},
force_update=True,
@@ -351,7 +350,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="hourly_gas_meter_reading",
- name="Gas consumption",
+ translation_key="gas_meter_reading",
obis_reference=obis_references.HOURLY_GAS_METER_READING,
dsmr_versions={"4", "5", "5L"},
is_gas=True,
@@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="belgium_5min_gas_meter_reading",
- name="Gas consumption",
+ translation_key="gas_meter_reading",
obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING,
dsmr_versions={"5B"},
is_gas=True,
@@ -371,7 +370,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
),
DSMRSensorEntityDescription(
key="gas_meter_reading",
- name="Gas consumption",
+ translation_key="gas_meter_reading",
obis_reference=obis_references.GAS_METER_READING,
dsmr_versions={"2.2"},
is_gas=True,
diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json
index 5db3a8bb8630..5724ad643fef 100644
--- a/homeassistant/components/dsmr/strings.json
+++ b/homeassistant/components/dsmr/strings.json
@@ -42,11 +42,105 @@
},
"entity": {
"sensor": {
- "electricity_tariff": {
+ "current_electricity_delivery": {
+ "name": "Power production"
+ },
+ "current_electricity_usage": {
+ "name": "Power consumption"
+ },
+ "electricity_active_tariff": {
+ "name": "Active tariff",
"state": {
"low": "Low",
"normal": "Normal"
}
+ },
+ "electricity_delivered_tariff_1": {
+ "name": "Energy production (tarif 1)"
+ },
+ "electricity_delivered_tariff_2": {
+ "name": "Energy production (tarif 2)"
+ },
+ "electricity_exported_total": {
+ "name": "Energy production (total)"
+ },
+ "electricity_imported_total": {
+ "name": "Energy consumption (total)"
+ },
+ "electricity_used_tariff_1": {
+ "name": "Energy consumption (tarif 1)"
+ },
+ "electricity_used_tariff_2": {
+ "name": "Energy consumption (tarif 2)"
+ },
+ "gas_meter_reading": {
+ "name": "Gas consumption"
+ },
+ "instantaneous_active_power_l1_negative": {
+ "name": "Power production phase L1"
+ },
+ "instantaneous_active_power_l1_positive": {
+ "name": "Power consumption phase L1"
+ },
+ "instantaneous_active_power_l2_negative": {
+ "name": "Power production phase L2"
+ },
+ "instantaneous_active_power_l2_positive": {
+ "name": "Power consumption phase L2"
+ },
+ "instantaneous_active_power_l3_negative": {
+ "name": "Power production phase L3"
+ },
+ "instantaneous_active_power_l3_positive": {
+ "name": "Power consumption phase L3"
+ },
+ "instantaneous_current_l1": {
+ "name": "Current phase L1"
+ },
+ "instantaneous_current_l2": {
+ "name": "Current phase L2"
+ },
+ "instantaneous_current_l3": {
+ "name": "Current phase L3"
+ },
+ "instantaneous_voltage_l1": {
+ "name": "Voltage phase L1"
+ },
+ "instantaneous_voltage_l2": {
+ "name": "Voltage phase L2"
+ },
+ "instantaneous_voltage_l3": {
+ "name": "Voltage phase L3"
+ },
+ "long_power_failure_count": {
+ "name": "Long power failure count"
+ },
+ "max_current_per_phase": {
+ "name": "Max current per phase"
+ },
+ "max_power_per_phase": {
+ "name": "Max power per phase"
+ },
+ "short_power_failure_count": {
+ "name": "Short power failure count"
+ },
+ "voltage_sag_l1_count": {
+ "name": "Voltage sags phase L1"
+ },
+ "voltage_sag_l2_count": {
+ "name": "Voltage sags phase L2"
+ },
+ "voltage_sag_l3_count": {
+ "name": "Voltage sags phase L3"
+ },
+ "voltage_swell_l1_count": {
+ "name": "Voltage swells phase L1"
+ },
+ "voltage_swell_l2_count": {
+ "name": "Voltage swells phase L2"
+ },
+ "voltage_swell_l3_count": {
+ "name": "Voltage swells phase L3"
}
}
},
diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py
index 2ba826d37e06..278c3c989db3 100644
--- a/homeassistant/components/duckdns/__init__.py
+++ b/homeassistant/components/duckdns/__init__.py
@@ -7,7 +7,13 @@ from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ HassJob,
+ HomeAssistant,
+ ServiceCall,
+ callback,
+)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_call_later
@@ -110,7 +116,7 @@ def async_track_time_interval_backoff(
"""Add a listener that fires repetitively at every timedelta interval."""
if not isinstance(intervals, (list, tuple)):
intervals = (intervals,)
- remove = None
+ remove: CALLBACK_TYPE | None = None
failed = 0
async def interval_listener(now: datetime) -> None:
@@ -122,8 +128,11 @@ def async_track_time_interval_backoff(
failed = 0
finally:
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
- remove = async_call_later(hass, delay.total_seconds(), interval_listener)
+ remove = async_call_later(
+ hass, delay.total_seconds(), interval_listener_job
+ )
+ interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True)
hass.async_run_job(interval_listener, dt_util.utcnow())
def remove_listener() -> None:
diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py
index 1841291f7a97..275d47d15ca0 100644
--- a/homeassistant/components/dwd_weather_warnings/__init__.py
+++ b/homeassistant/components/dwd_weather_warnings/__init__.py
@@ -1 +1,35 @@
"""The dwd_weather_warnings component."""
+
+from __future__ import annotations
+
+from dwdwfsapi import DwdWeatherWarningsAPI
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS
+from .coordinator import DwdWeatherWarningsCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a config entry."""
+ region_identifier: str = entry.data[CONF_REGION_IDENTIFIER]
+
+ # Initialize the API and coordinator.
+ api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier)
+ coordinator = DwdWeatherWarningsCoordinator(hass, api)
+
+ 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
diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py
new file mode 100644
index 000000000000..e806db7ec91e
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/config_flow.py
@@ -0,0 +1,77 @@
+"""Config flow for the dwd_weather_warnings integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from dwdwfsapi import DwdWeatherWarningsAPI
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow
+from homeassistant.const import CONF_NAME
+from homeassistant.data_entry_flow import FlowResult
+import homeassistant.helpers.config_validation as cv
+
+from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER
+
+
+class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle the config flow for the dwd_weather_warnings integration."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle the initial step."""
+ errors: dict = {}
+
+ if user_input is not None:
+ region_identifier = user_input[CONF_REGION_IDENTIFIER]
+
+ # Validate region identifier using the API
+ if not await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, region_identifier
+ ):
+ errors["base"] = "invalid_identifier"
+
+ if not errors:
+ # Set the unique ID for this config entry.
+ await self.async_set_unique_id(region_identifier)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(title=region_identifier, data=user_input)
+
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_REGION_IDENTIFIER): cv.string,
+ }
+ ),
+ )
+
+ async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
+ """Import a config entry from configuration.yaml."""
+ LOGGER.debug(
+ "Starting import of sensor from configuration.yaml - %s", import_config
+ )
+
+ # Extract the necessary data for the setup.
+ region_identifier = import_config[CONF_REGION_NAME]
+ name = import_config.get(CONF_NAME, region_identifier)
+
+ # Set the unique ID for this imported entry.
+ await self.async_set_unique_id(region_identifier)
+ self._abort_if_unique_id_configured()
+
+ # Validate region identifier using the API
+ if not await self.hass.async_add_executor_job(
+ DwdWeatherWarningsAPI, region_identifier
+ ):
+ return self.async_abort(reason="invalid_identifier")
+
+ return self.async_create_entry(
+ title=name, data={CONF_REGION_IDENTIFIER: region_identifier}
+ )
diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py
index af8786f8d77d..75969dee119e 100644
--- a/homeassistant/components/dwd_weather_warnings/const.py
+++ b/homeassistant/components/dwd_weather_warnings/const.py
@@ -6,9 +6,14 @@ from datetime import timedelta
import logging
from typing import Final
+from homeassistant.const import Platform
+
LOGGER = logging.getLogger(__package__)
+DOMAIN: Final = "dwd_weather_warnings"
+
CONF_REGION_NAME: Final = "region_name"
+CONF_REGION_IDENTIFIER: Final = "region_identifier"
ATTR_REGION_NAME: Final = "region_name"
ATTR_REGION_ID: Final = "region_id"
@@ -29,5 +34,7 @@ API_ATTR_WARNING_COLOR: Final = "color"
CURRENT_WARNING_SENSOR: Final = "current_warning_level"
ADVANCE_WARNING_SENSOR: Final = "advance_warning_level"
-DEFAULT_NAME: Final = "DWD-Weather-Warnings"
+DEFAULT_NAME: Final = "DWD Weather Warnings"
DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15)
+
+PLATFORMS: Final[list[Platform]] = [Platform.SENSOR]
diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py
new file mode 100644
index 000000000000..a12326971306
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/coordinator.py
@@ -0,0 +1,26 @@
+"""Data coordinator for the dwd_weather_warnings integration."""
+
+from __future__ import annotations
+
+from dwdwfsapi import DwdWeatherWarningsAPI
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
+
+
+class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]):
+ """Custom coordinator for the dwd_weather_warnings integration."""
+
+ def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None:
+ """Initialize the dwd_weather_warnings coordinator."""
+ super().__init__(
+ hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL
+ )
+
+ self.api = api
+
+ async def _async_update_data(self) -> None:
+ """Get the latest data from the DWD Weather Warnings API."""
+ await self.hass.async_add_executor_job(self.api.update)
diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json
index 2a22d5f8fb22..a383e33eab26 100644
--- a/homeassistant/components/dwd_weather_warnings/manifest.json
+++ b/homeassistant/components/dwd_weather_warnings/manifest.json
@@ -2,6 +2,7 @@
"domain": "dwd_weather_warnings",
"name": "Deutscher Wetterdienst (DWD) Weather Warnings",
"codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings",
"iot_class": "cloud_polling",
"loggers": ["dwdwfsapi"],
diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py
index 054d9e5ca8b7..3e8ed2afbdc6 100644
--- a/homeassistant/components/dwd_weather_warnings/sensor.py
+++ b/homeassistant/components/dwd_weather_warnings/sensor.py
@@ -8,9 +8,11 @@ Unwetterwarnungen (Stufe 3)
Warnungen vor markantem Wetter (Stufe 2)
Wetterwarnungen (Stufe 1)
"""
+
from __future__ import annotations
-from dwdwfsapi import DwdWeatherWarningsAPI
+from typing import Final
+
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -18,12 +20,14 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ADVANCE_WARNING_SENSOR,
@@ -44,9 +48,9 @@ from .const import (
CONF_REGION_NAME,
CURRENT_WARNING_SENSOR,
DEFAULT_NAME,
- DEFAULT_SCAN_INTERVAL,
- LOGGER,
+ DOMAIN,
)
+from .coordinator import DwdWeatherWarningsCoordinator
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -60,77 +64,104 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
icon="mdi:close-octagon-outline",
),
)
-MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES]
+# Should be removed together with the old YAML configuration.
+YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_REGION_NAME): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
vol.Optional(
- CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS)
- ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
+ CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS
+ ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]),
}
)
-def setup_platform(
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
- """Set up the DWD-Weather-Warnings sensor."""
- name = config.get(CONF_NAME)
- region_name = config.get(CONF_REGION_NAME)
+ """Import the configurations from YAML to config flows."""
+ # Show issue as long as the YAML configuration exists.
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ )
- api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name))
-
- sensors = [
- DwdWeatherWarningsSensor(api, name, description)
- for description in SENSOR_TYPES
- if description.key in config[CONF_MONITORED_CONDITIONS]
- ]
-
- add_entities(sensors, True)
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ )
-class DwdWeatherWarningsSensor(SensorEntity):
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up entities from config entry."""
+ coordinator = hass.data[DOMAIN][entry.entry_id]
+
+ async_add_entities(
+ [
+ DwdWeatherWarningsSensor(coordinator, entry, description)
+ for description in SENSOR_TYPES
+ ],
+ True,
+ )
+
+
+class DwdWeatherWarningsSensor(
+ CoordinatorEntity[DwdWeatherWarningsCoordinator], SensorEntity
+):
"""Representation of a DWD-Weather-Warnings sensor."""
_attr_attribution = "Data provided by DWD"
def __init__(
self,
- api,
- name,
+ coordinator: DwdWeatherWarningsCoordinator,
+ entry: ConfigEntry,
description: SensorEntityDescription,
) -> None:
"""Initialize a DWD-Weather-Warnings sensor."""
- self._api = api
+ super().__init__(coordinator)
+
self.entity_description = description
- self._attr_name = f"{name} {description.name}"
+ self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}"
+ self._attr_unique_id = f"{entry.unique_id}-{description.key}"
+
+ self.api = coordinator.api
@property
def native_value(self):
- """Return the state of the device."""
+ """Return the state of the sensor."""
if self.entity_description.key == CURRENT_WARNING_SENSOR:
- return self._api.api.current_warning_level
- return self._api.api.expected_warning_level
+ return self.api.current_warning_level
+
+ return self.api.expected_warning_level
@property
def extra_state_attributes(self):
- """Return the state attributes of the DWD-Weather-Warnings."""
+ """Return the state attributes of the sensor."""
data = {
- ATTR_REGION_NAME: self._api.api.warncell_name,
- ATTR_REGION_ID: self._api.api.warncell_id,
- ATTR_LAST_UPDATE: self._api.api.last_update,
+ ATTR_REGION_NAME: self.api.warncell_name,
+ ATTR_REGION_ID: self.api.warncell_id,
+ ATTR_LAST_UPDATE: self.api.last_update,
}
if self.entity_description.key == CURRENT_WARNING_SENSOR:
- searched_warnings = self._api.api.current_warnings
+ searched_warnings = self.api.current_warnings
else:
- searched_warnings = self._api.api.expected_warnings
+ searched_warnings = self.api.expected_warnings
data[ATTR_WARNING_COUNT] = len(searched_warnings)
@@ -146,7 +177,7 @@ class DwdWeatherWarningsSensor(SensorEntity):
data[f"warning_{i}_parameters"] = warning[API_ATTR_WARNING_PARAMETERS]
data[f"warning_{i}_color"] = warning[API_ATTR_WARNING_COLOR]
- # Dictionary for the attribute containing the complete warning
+ # Dictionary for the attribute containing the complete warning.
warning_copy = warning.copy()
warning_copy[API_ATTR_WARNING_START] = data[f"warning_{i}_start"]
warning_copy[API_ATTR_WARNING_END] = data[f"warning_{i}_end"]
@@ -157,28 +188,4 @@ class DwdWeatherWarningsSensor(SensorEntity):
@property
def available(self) -> bool:
"""Could the device be accessed during the last update call."""
- return self._api.api.data_valid
-
- def update(self) -> None:
- """Get the latest data from the DWD-Weather-Warnings API."""
- LOGGER.debug(
- "Update requested for %s (%s) by %s",
- self._api.api.warncell_name,
- self._api.api.warncell_id,
- self.entity_description.key,
- )
- self._api.update()
-
-
-class WrappedDwDWWAPI:
- """Wrapper for the DWD-Weather-Warnings api."""
-
- def __init__(self, api):
- """Initialize a DWD-Weather-Warnings wrapper."""
- self.api = api
-
- @Throttle(DEFAULT_SCAN_INTERVAL)
- def update(self):
- """Get the latest data from the DWD-Weather-Warnings API."""
- self.api.update()
- LOGGER.debug("Update performed")
+ return self.api.data_valid
diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json
new file mode 100644
index 000000000000..c5c954a9f8e5
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/strings.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "To identify the desired region, the warncell ID / name is required.",
+ "data": {
+ "region_identifier": "Warncell ID or name"
+ }
+ }
+ },
+ "error": {
+ "invalid_identifier": "The specified region identifier is invalid."
+ },
+ "abort": {
+ "already_configured": "Warncell ID / name is already configured.",
+ "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml": {
+ "title": "The Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration is being removed",
+ "description": "Configuring Deutscher Wetterdienst (DWD) Weather Warnings using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index fe1872e1fe33..77880fd74cbe 100644
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -1,14 +1,11 @@
"""Support for the Dynalite networks."""
from __future__ import annotations
-from typing import Any
-
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
@@ -17,36 +14,10 @@ from homeassistant.helpers.typing import ConfigType
# Loading the config flow file will register the flow
from .bridge import DynaliteBridge
from .const import (
- ACTIVE_INIT,
- ACTIVE_OFF,
- ACTIVE_ON,
ATTR_AREA,
ATTR_CHANNEL,
ATTR_HOST,
- CONF_ACTIVE,
- CONF_AREA,
- CONF_AUTO_DISCOVER,
CONF_BRIDGES,
- CONF_CHANNEL,
- CONF_CHANNEL_COVER,
- CONF_CLOSE_PRESET,
- CONF_DEVICE_CLASS,
- CONF_DURATION,
- CONF_FADE,
- CONF_LEVEL,
- CONF_NO_DEFAULT,
- CONF_OPEN_PRESET,
- CONF_POLL_TIMER,
- CONF_PRESET,
- CONF_ROOM_OFF,
- CONF_ROOM_ON,
- CONF_STOP_PRESET,
- CONF_TEMPLATE,
- CONF_TILT_TIME,
- DEFAULT_CHANNEL_TYPE,
- DEFAULT_NAME,
- DEFAULT_PORT,
- DEFAULT_TEMPLATES,
DOMAIN,
LOGGER,
PLATFORMS,
@@ -54,147 +25,29 @@ from .const import (
SERVICE_REQUEST_CHANNEL_LEVEL,
)
from .convert_config import convert_config
-
-
-def num_string(value: int | str) -> str:
- """Test if value is a string of digits, aka an integer."""
- new_value = str(value)
- if new_value.isdigit():
- return new_value
- raise vol.Invalid("Not a string with numbers")
-
-
-CHANNEL_DATA_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_FADE): vol.Coerce(float),
- vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any(
- "light", "switch"
- ),
- }
-)
-
-CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA})
-
-PRESET_DATA_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_FADE): vol.Coerce(float),
- vol.Optional(CONF_LEVEL): vol.Coerce(float),
- }
-)
-
-PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)})
-
-TEMPLATE_ROOM_SCHEMA = vol.Schema(
- {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string}
-)
-
-TEMPLATE_TIMECOVER_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_CHANNEL_COVER): num_string,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(CONF_OPEN_PRESET): num_string,
- vol.Optional(CONF_CLOSE_PRESET): num_string,
- vol.Optional(CONF_STOP_PRESET): num_string,
- vol.Optional(CONF_DURATION): vol.Coerce(float),
- vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
- }
-)
-
-TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA)
-
-TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA})
-
-
-def validate_area(config: dict[str, Any]) -> dict[str, Any]:
- """Validate that template parameters are only used if area is using the relevant template."""
- conf_set = set()
- for configs in DEFAULT_TEMPLATES.values():
- for conf in configs:
- conf_set.add(conf)
- if config.get(CONF_TEMPLATE):
- for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]:
- conf_set.remove(conf)
- for conf in conf_set:
- if config.get(conf):
- raise vol.Invalid(
- f"{conf} should not be part of area {config[CONF_NAME]} config"
- )
- return config
-
-
-AREA_DATA_SCHEMA = vol.Schema(
- vol.All(
- {
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES),
- vol.Optional(CONF_FADE): vol.Coerce(float),
- vol.Optional(CONF_NO_DEFAULT): cv.boolean,
- vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA,
- vol.Optional(CONF_PRESET): PRESET_SCHEMA,
- # the next ones can be part of the templates
- vol.Optional(CONF_ROOM_ON): num_string,
- vol.Optional(CONF_ROOM_OFF): num_string,
- vol.Optional(CONF_CHANNEL_COVER): num_string,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(CONF_OPEN_PRESET): num_string,
- vol.Optional(CONF_CLOSE_PRESET): num_string,
- vol.Optional(CONF_STOP_PRESET): num_string,
- vol.Optional(CONF_DURATION): vol.Coerce(float),
- vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
- },
- validate_area,
- )
-)
-
-AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)})
-
-PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)})
-
-
-BRIDGE_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
- vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool),
- vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float),
- vol.Optional(CONF_AREA): AREA_SCHEMA,
- vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA,
- vol.Optional(CONF_ACTIVE, default=False): vol.Any(
- ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean
- ),
- vol.Optional(CONF_PRESET): PRESET_SCHEMA,
- vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA,
- }
-)
+from .panel import async_register_dynalite_frontend
+from .schema import BRIDGE_SCHEMA
CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])}
- )
- },
+ vol.All(
+ cv.deprecated(DOMAIN),
+ {
+ DOMAIN: vol.Schema(
+ {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])}
+ ),
+ },
+ ),
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform."""
- conf = config.get(DOMAIN)
+ conf = config.get(DOMAIN, {})
LOGGER.debug("Setting up dynalite component config = %s", conf)
-
- if conf is None:
- conf = {}
-
hass.data[DOMAIN] = {}
- # User has configured bridges
- if CONF_BRIDGES not in conf:
- return True
-
- bridges = conf[CONF_BRIDGES]
+ bridges = conf.get(CONF_BRIDGES, [])
for bridge_conf in bridges:
host = bridge_conf[CONF_HOST]
@@ -277,6 +130,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await async_register_dynalite_frontend(hass)
+
return True
diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py
index d723825319a6..946d4ac653d7 100644
--- a/homeassistant/components/dynalite/config_flow.py
+++ b/homeassistant/components/dynalite/config_flow.py
@@ -3,12 +3,16 @@ from __future__ import annotations
from typing import Any
+import voluptuous as vol
+
from homeassistant import config_entries
-from homeassistant.const import CONF_HOST
+from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .bridge import DynaliteBridge
-from .const import DOMAIN, LOGGER
+from .const import DEFAULT_PORT, DOMAIN, LOGGER
from .convert_config import convert_config
@@ -23,8 +27,20 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
"""Import a new bridge as a config entry."""
- LOGGER.debug("Starting async_step_import - %s", import_info)
+ LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info)
+ # Raise an issue that this is deprecated and has been imported
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ "deprecated_yaml",
+ is_fixable=False,
+ is_persistent=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ )
+
host = import_info[CONF_HOST]
+ # Check if host already exists
for entry in self._async_current_entries():
if entry.data[CONF_HOST] == host:
self.hass.config_entries.async_update_entry(
@@ -33,9 +49,34 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_configured")
# New entry
- bridge = DynaliteBridge(self.hass, convert_config(import_info))
+ return await self._try_create(import_info)
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Step when user initializes a integration."""
+ if user_input is not None:
+ return await self._try_create(user_input)
+
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ }
+ )
+ return self.async_show_form(step_id="user", data_schema=schema)
+
+ async def _try_create(self, info: dict[str, Any]) -> FlowResult:
+ """Try to connect and if successful, create entry."""
+ host = info[CONF_HOST]
+ configured_hosts = [
+ entry.data[CONF_HOST] for entry in self._async_current_entries()
+ ]
+ if host in configured_hosts:
+ return self.async_abort(reason="already_configured")
+ bridge = DynaliteBridge(self.hass, convert_config(info))
if not await bridge.async_setup():
- LOGGER.error("Unable to setup bridge - import info=%s", import_info)
- return self.async_abort(reason="no_connection")
- LOGGER.debug("Creating entry for the bridge - %s", import_info)
- return self.async_create_entry(title=host, data=import_info)
+ LOGGER.error("Unable to setup bridge - import info=%s", info)
+ return self.async_abort(reason="cannot_connect")
+ LOGGER.debug("Creating entry for the bridge - %s", info)
+ return self.async_create_entry(title=info[CONF_HOST], data=info)
diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json
index cded6a7e365a..a3dd890cc11f 100644
--- a/homeassistant/components/dynalite/manifest.json
+++ b/homeassistant/components/dynalite/manifest.json
@@ -1,10 +1,12 @@
{
"domain": "dynalite",
"name": "Philips Dynalite",
+ "after_dependencies": ["panel_custom"],
"codeowners": ["@ziv1234"],
"config_flow": true,
+ "dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/dynalite",
"iot_class": "local_push",
"loggers": ["dynalite_devices_lib"],
- "requirements": ["dynalite_devices==0.1.47"]
+ "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"]
}
diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py
new file mode 100644
index 000000000000..e7a0890033c3
--- /dev/null
+++ b/homeassistant/components/dynalite/panel.py
@@ -0,0 +1,117 @@
+"""Dynalite API interface for the frontend."""
+
+from dynalite_panel import get_build_id, locate_dir
+import voluptuous as vol
+
+from homeassistant.components import panel_custom, websocket_api
+from homeassistant.components.cover import DEVICE_CLASSES
+from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.core import HomeAssistant, callback
+
+from .const import (
+ CONF_ACTIVE,
+ CONF_AREA,
+ CONF_AUTO_DISCOVER,
+ CONF_PRESET,
+ CONF_TEMPLATE,
+ DEFAULT_NAME,
+ DEFAULT_PORT,
+ DOMAIN,
+ LOGGER,
+)
+from .schema import BRIDGE_SCHEMA
+
+URL_BASE = "/dynalite_static"
+
+RELEVANT_CONFS = [
+ CONF_NAME,
+ CONF_HOST,
+ CONF_PORT,
+ CONF_AUTO_DISCOVER,
+ CONF_AREA,
+ CONF_DEFAULT,
+ CONF_ACTIVE,
+ CONF_PRESET,
+ CONF_TEMPLATE,
+]
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "dynalite/get-config",
+ }
+)
+@websocket_api.require_admin
+@callback
+def get_dynalite_config(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
+) -> None:
+ """Retrieve the Dynalite config for the frontend."""
+ entries = hass.config_entries.async_entries(DOMAIN)
+ relevant_config = {
+ entry.entry_id: {
+ conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data
+ }
+ for entry in entries
+ }
+ dynalite_defaults = {
+ "DEFAULT_NAME": DEFAULT_NAME,
+ "DEVICE_CLASSES": DEVICE_CLASSES,
+ "DEFAULT_PORT": DEFAULT_PORT,
+ }
+ connection.send_result(
+ msg["id"], {"config": relevant_config, "default": dynalite_defaults}
+ )
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "dynalite/save-config",
+ vol.Required("entry_id"): str,
+ vol.Required("config"): BRIDGE_SCHEMA,
+ }
+)
+@websocket_api.require_admin
+@callback
+def save_dynalite_config(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
+) -> None:
+ """Retrieve the Dynalite config for the frontend."""
+ entry_id = msg["entry_id"]
+ entry = hass.config_entries.async_get_entry(entry_id)
+ if not entry:
+ LOGGER.error(
+ "Dynalite - received updated config for invalid entry - %s", entry_id
+ )
+ connection.send_result(msg["id"], {"error": True})
+ return
+ message_conf = msg["config"]
+ message_data = {
+ conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf
+ }
+ LOGGER.info("Updating Dynalite config entry")
+ hass.config_entries.async_update_entry(entry, data=message_data)
+ connection.send_result(msg["id"], {})
+
+
+async def async_register_dynalite_frontend(hass: HomeAssistant):
+ """Register the Dynalite frontend configuration panel."""
+ websocket_api.async_register_command(hass, get_dynalite_config)
+ websocket_api.async_register_command(hass, save_dynalite_config)
+ if DOMAIN not in hass.data.get("frontend_panels", {}):
+ path = locate_dir()
+ build_id = get_build_id()
+ hass.http.register_static_path(
+ URL_BASE, path, cache_headers=(build_id != "dev")
+ )
+
+ await panel_custom.async_register_panel(
+ hass=hass,
+ frontend_url_path=DOMAIN,
+ webcomponent_name="dynalite-panel",
+ sidebar_title=DOMAIN.capitalize(),
+ sidebar_icon="mdi:power",
+ module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
+ embed_iframe=True,
+ require_admin=True,
+ )
diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py
new file mode 100644
index 000000000000..d470243782b4
--- /dev/null
+++ b/homeassistant/components/dynalite/schema.py
@@ -0,0 +1,155 @@
+"""Schema for config entries."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA
+from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE
+from homeassistant.helpers import config_validation as cv
+
+from .const import (
+ ACTIVE_INIT,
+ ACTIVE_OFF,
+ ACTIVE_ON,
+ CONF_ACTIVE,
+ CONF_AREA,
+ CONF_AUTO_DISCOVER,
+ CONF_CHANNEL,
+ CONF_CHANNEL_COVER,
+ CONF_CLOSE_PRESET,
+ CONF_DEVICE_CLASS,
+ CONF_DURATION,
+ CONF_FADE,
+ CONF_LEVEL,
+ CONF_NO_DEFAULT,
+ CONF_OPEN_PRESET,
+ CONF_POLL_TIMER,
+ CONF_PRESET,
+ CONF_ROOM_OFF,
+ CONF_ROOM_ON,
+ CONF_STOP_PRESET,
+ CONF_TEMPLATE,
+ CONF_TILT_TIME,
+ DEFAULT_CHANNEL_TYPE,
+ DEFAULT_NAME,
+ DEFAULT_PORT,
+ DEFAULT_TEMPLATES,
+)
+
+
+def num_string(value: str | int) -> str:
+ """Test if value is a string of digits, aka an integer."""
+ new_value = str(value)
+ if new_value.isdigit():
+ return new_value
+ raise vol.Invalid("Not a string with numbers")
+
+
+CHANNEL_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_FADE): vol.Coerce(float),
+ vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any(
+ "light", "switch"
+ ),
+ }
+)
+
+CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA})
+
+PRESET_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_FADE): vol.Coerce(float),
+ vol.Optional(CONF_LEVEL): vol.Coerce(float),
+ }
+)
+
+PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)})
+
+TEMPLATE_ROOM_SCHEMA = vol.Schema(
+ {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string}
+)
+
+TEMPLATE_TIMECOVER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_CHANNEL_COVER): num_string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_OPEN_PRESET): num_string,
+ vol.Optional(CONF_CLOSE_PRESET): num_string,
+ vol.Optional(CONF_STOP_PRESET): num_string,
+ vol.Optional(CONF_DURATION): vol.Coerce(float),
+ vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
+ }
+)
+
+TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA)
+
+TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA})
+
+
+def validate_area(config: dict[str, Any]) -> dict[str, Any]:
+ """Validate that template parameters are only used if area is using the relevant template."""
+ conf_set = set()
+ for configs in DEFAULT_TEMPLATES.values():
+ for conf in configs:
+ conf_set.add(conf)
+ if config.get(CONF_TEMPLATE):
+ for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]:
+ conf_set.remove(conf)
+ for conf in conf_set:
+ if config.get(conf):
+ raise vol.Invalid(
+ f"{conf} should not be part of area {config[CONF_NAME]} config"
+ )
+ return config
+
+
+AREA_DATA_SCHEMA = vol.Schema(
+ vol.All(
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES),
+ vol.Optional(CONF_FADE): vol.Coerce(float),
+ vol.Optional(CONF_NO_DEFAULT): cv.boolean,
+ vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA,
+ vol.Optional(CONF_PRESET): PRESET_SCHEMA,
+ # the next ones can be part of the templates
+ vol.Optional(CONF_ROOM_ON): num_string,
+ vol.Optional(CONF_ROOM_OFF): num_string,
+ vol.Optional(CONF_CHANNEL_COVER): num_string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_OPEN_PRESET): num_string,
+ vol.Optional(CONF_CLOSE_PRESET): num_string,
+ vol.Optional(CONF_STOP_PRESET): num_string,
+ vol.Optional(CONF_DURATION): vol.Coerce(float),
+ vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
+ },
+ validate_area,
+ )
+)
+
+AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)})
+
+PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)})
+
+
+BRIDGE_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool),
+ vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float),
+ vol.Optional(CONF_AREA): AREA_SCHEMA,
+ vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA,
+ vol.Optional(CONF_ACTIVE, default=False): vol.Any(
+ ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean
+ ),
+ vol.Optional(CONF_PRESET): PRESET_SCHEMA,
+ vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA,
+ }
+)
diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json
new file mode 100644
index 000000000000..1d78108f909f
--- /dev/null
+++ b/homeassistant/components/dynalite/strings.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "title": "Configure Dynalite Connection",
+ "description": "Gateway address to connect to DYNET network"
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml": {
+ "title": "The Dynalite YAML configuration is being removed",
+ "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py
index 3d92f63b752e..3996fd4d16ab 100644
--- a/homeassistant/components/easyenergy/coordinator.py
+++ b/homeassistant/components/easyenergy/coordinator.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR
@@ -47,7 +47,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
async def _async_update_data(self) -> EasyEnergyData:
"""Fetch data from easyEnergy."""
- today = dt.now().date()
+ today = dt_util.now().date()
gas_today = None
energy_tomorrow = None
@@ -62,7 +62,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
except EasyEnergyNoDataError:
LOGGER.debug("No data for gas prices for easyEnergy integration")
# Energy for tomorrow only after 14:00 UTC
- if dt.utcnow().hour >= THRESHOLD_HOUR:
+ if dt_util.utcnow().hour >= THRESHOLD_HOUR:
tomorrow = today + timedelta(days=1)
try:
energy_tomorrow = await self.easyenergy.energy_prices(
diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json
index 546202fa9c53..9f0f668ee81f 100644
--- a/homeassistant/components/ecowitt/manifest.json
+++ b/homeassistant/components/ecowitt/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
- "requirements": ["aioecowitt==2023.01.0"]
+ "requirements": ["aioecowitt==2023.5.0"]
}
diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py
index b66a988958b9..0bedcc515efd 100644
--- a/homeassistant/components/edl21/config_flow.py
+++ b/homeassistant/components/edl21/config_flow.py
@@ -1,10 +1,8 @@
"""Config flow for EDL21 integration."""
-from typing import Any
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN
@@ -21,17 +19,6 @@ class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
- async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
- """Import a config entry from configuration.yaml."""
-
- self._async_abort_entries_match(
- {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]}
- )
- return self.async_create_entry(
- title=import_config[CONF_NAME] or DEFAULT_TITLE,
- data=import_config,
- )
-
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json
index 034d3fcae2e1..faa471e44b19 100644
--- a/homeassistant/components/edl21/manifest.json
+++ b/homeassistant/components/edl21/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["sml"],
- "requirements": ["pysml==0.0.10"]
+ "requirements": ["pysml==0.0.12"]
}
diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py
index df9606475ffa..c2436c150572 100644
--- a/homeassistant/components/edl21/sensor.py
+++ b/homeassistant/components/edl21/sensor.py
@@ -7,16 +7,14 @@ from typing import Any
from sml import SmlGetListResponse
from sml.asyncio import SmlProtocol
-import voluptuous as vol
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
DEGREE,
@@ -27,15 +25,12 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import utcnow
from .const import (
@@ -48,13 +43,6 @@ from .const import (
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_SERIAL_PORT): cv.string,
- vol.Optional(CONF_NAME, default=""): cv.string,
- },
-)
-
# OBIS format: A-B:C.D.E*F
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
# A=1: Electricity
@@ -279,31 +267,6 @@ SENSOR_UNIT_MAPPING = {
}
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up EDL21 sensors via configuration.yaml and show deprecation warning."""
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2023.6.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- )
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- )
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -344,6 +307,11 @@ class EDL21:
self._name = config.get(CONF_NAME)
self._proto = SmlProtocol(config[CONF_SERIAL_PORT])
self._proto.add_listener(self.event, ["SmlGetListResponse"])
+ LOGGER.debug(
+ "Initialized EDL21 for %s on %s",
+ config.get(CONF_NAME),
+ config[CONF_SERIAL_PORT],
+ )
async def connect(self) -> None:
"""Connect to an EDL21 reader."""
@@ -352,14 +320,12 @@ class EDL21:
def event(self, message_body) -> None:
"""Handle events from pysml."""
assert isinstance(message_body, SmlGetListResponse)
+ LOGGER.debug("Received sml message for %s: %s", self._name, message_body)
- electricity_id = None
- for telegram in message_body.get("valList", []):
- if telegram.get("objName") in ("1-0:0.0.9*255", "1-0:96.1.0*255"):
- electricity_id = telegram.get("value")
- break
+ electricity_id = message_body["serverId"]
if electricity_id is None:
+ LOGGER.debug("No electricity id found in sml message for %s", self._name)
return
electricity_id = electricity_id.replace(" ", "")
diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json
index 284e8229c59b..764cc41d2a40 100644
--- a/homeassistant/components/edl21/strings.json
+++ b/homeassistant/components/edl21/strings.json
@@ -11,11 +11,5 @@
}
}
}
- },
- "issues": {
- "deprecated_yaml": {
- "title": "EDL21 YAML configuration is being removed",
- "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py
new file mode 100644
index 000000000000..6fb9c35757f8
--- /dev/null
+++ b/homeassistant/components/electrasmart/__init__.py
@@ -0,0 +1,46 @@
+"""The Electra Air Conditioner integration."""
+from __future__ import annotations
+
+from typing import cast
+
+from electrasmart.api import ElectraAPI, ElectraApiError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_TOKEN, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_IMEI, DOMAIN
+
+PLATFORMS: list[Platform] = [Platform.CLIMATE]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Electra Smart Air Conditioner from a config entry."""
+ hass.data.setdefault(DOMAIN, {})
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+ hass.data[DOMAIN][entry.entry_id] = ElectraAPI(
+ async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN]
+ )
+
+ try:
+ await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices()
+ except ElectraApiError as exp:
+ raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp
+
+ 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
+
+
+async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ """Update listener."""
+ await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py
new file mode 100644
index 000000000000..361f906133d4
--- /dev/null
+++ b/homeassistant/components/electrasmart/climate.py
@@ -0,0 +1,334 @@
+"""Support for the Electra climate."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+import time
+from typing import Any
+
+from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError
+from electrasmart.device import ElectraAirConditioner, OperationMode
+from electrasmart.device.const import MAX_TEMP, MIN_TEMP, Feature
+
+from homeassistant.components.climate import (
+ FAN_AUTO,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ SWING_BOTH,
+ SWING_HORIZONTAL,
+ SWING_OFF,
+ SWING_VERTICAL,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import (
+ API_DELAY,
+ CONSECUTIVE_FAILURE_THRESHOLD,
+ DOMAIN,
+ PRESET_NONE,
+ PRESET_SHABAT,
+ SCAN_INTERVAL_SEC,
+ UNAVAILABLE_THRESH_SEC,
+)
+
+FAN_ELECTRA_TO_HASS = {
+ OperationMode.FAN_SPEED_AUTO: FAN_AUTO,
+ OperationMode.FAN_SPEED_LOW: FAN_LOW,
+ OperationMode.FAN_SPEED_MED: FAN_MEDIUM,
+ OperationMode.FAN_SPEED_HIGH: FAN_HIGH,
+}
+
+FAN_HASS_TO_ELECTRA = {
+ FAN_AUTO: OperationMode.FAN_SPEED_AUTO,
+ FAN_LOW: OperationMode.FAN_SPEED_LOW,
+ FAN_MEDIUM: OperationMode.FAN_SPEED_MED,
+ FAN_HIGH: OperationMode.FAN_SPEED_HIGH,
+}
+
+HVAC_MODE_ELECTRA_TO_HASS = {
+ OperationMode.MODE_COOL: HVACMode.COOL,
+ OperationMode.MODE_HEAT: HVACMode.HEAT,
+ OperationMode.MODE_FAN: HVACMode.FAN_ONLY,
+ OperationMode.MODE_DRY: HVACMode.DRY,
+ OperationMode.MODE_AUTO: HVACMode.AUTO,
+}
+
+HVAC_MODE_HASS_TO_ELECTRA = {
+ HVACMode.COOL: OperationMode.MODE_COOL,
+ HVACMode.HEAT: OperationMode.MODE_HEAT,
+ HVACMode.FAN_ONLY: OperationMode.MODE_FAN,
+ HVACMode.DRY: OperationMode.MODE_DRY,
+ HVACMode.AUTO: OperationMode.MODE_AUTO,
+}
+
+ELECTRA_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW]
+ELECTRA_MODES = [
+ HVACMode.OFF,
+ HVACMode.HEAT,
+ HVACMode.COOL,
+ HVACMode.DRY,
+ HVACMode.FAN_ONLY,
+ HVACMode.AUTO,
+]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SCAN_INTERVAL = timedelta(seconds=SCAN_INTERVAL_SEC)
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Add Electra AC devices."""
+ api: ElectraAPI = hass.data[DOMAIN][entry.entry_id]
+
+ _LOGGER.debug("Discovered %i Electra devices", len(api.devices))
+ async_add_entities(
+ (ElectraClimateEntity(device, api) for device in api.devices), True
+ )
+
+
+class ElectraClimateEntity(ClimateEntity):
+ """Define an Electra climate."""
+
+ _attr_fan_modes = ELECTRA_FAN_MODES
+ _attr_target_temperature_step = 1
+ _attr_max_temp = MAX_TEMP
+ _attr_min_temp = MIN_TEMP
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_hvac_modes = ELECTRA_MODES
+
+ def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None:
+ """Initialize Electra climate entity."""
+ self._api = api
+ self._electra_ac_device = device
+ self._attr_name = device.name
+ self._attr_unique_id = device.mac
+ self._attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
+ )
+
+ swing_modes: list = []
+ if Feature.V_SWING in self._electra_ac_device.features:
+ swing_modes.append(SWING_VERTICAL)
+ if Feature.H_SWING in self._electra_ac_device.features:
+ swing_modes.append(SWING_HORIZONTAL)
+
+ if all(elem in [SWING_HORIZONTAL, SWING_VERTICAL] for elem in swing_modes):
+ swing_modes.append(SWING_BOTH)
+ if swing_modes:
+ swing_modes.append(SWING_OFF)
+ self._attr_swing_modes = swing_modes
+ self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+
+ self._attr_preset_modes = [
+ PRESET_NONE,
+ PRESET_SHABAT,
+ ]
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._electra_ac_device.mac)},
+ name=self.name,
+ model=self._electra_ac_device.model,
+ manufacturer=self._electra_ac_device.manufactor,
+ )
+
+ # This attribute will be used to mark the time we communicated
+ # a command to the API
+ self._last_state_update = 0
+
+ # count the consecutive update failures, used to print error log
+ self._consecutive_failures = 0
+ self._skip_update = True
+ self._was_available = True
+
+ _LOGGER.debug("Added %s Electra AC device", self._attr_name)
+
+ @property
+ def available(self) -> bool:
+ """Return True if the AC is available."""
+ return (
+ not self._electra_ac_device.is_disconnected(UNAVAILABLE_THRESH_SEC)
+ and super().available
+ )
+
+ async def async_update(self) -> None:
+ """Update Electra device."""
+
+ # if we communicated a change to the API in the last API_DELAY seconds,
+ # then don't receive any updates as the API takes few seconds
+ # until it start sending it last recent change
+ if self._last_state_update and int(time.time()) < (
+ self._last_state_update + API_DELAY
+ ):
+ _LOGGER.debug("Skipping state update, keeping old values")
+ return
+
+ self._last_state_update = 0
+
+ try:
+ # skip the first update only as we already got the devices with their current state
+ if self._skip_update:
+ self._skip_update = False
+ else:
+ await self._api.get_last_telemtry(self._electra_ac_device)
+
+ if not self.available:
+ # show the warning once upon state change
+ if self._was_available:
+ _LOGGER.warning(
+ "Electra AC %s (%s) is not available, check its status in the Electra Smart mobile app",
+ self.name,
+ self._electra_ac_device.mac,
+ )
+ self._was_available = False
+ return
+
+ if not self._was_available:
+ _LOGGER.info(
+ "%s (%s) is now available",
+ self._electra_ac_device.mac,
+ self.name,
+ )
+ self._was_available = True
+
+ _LOGGER.debug(
+ "%s (%s) state updated: %s",
+ self._electra_ac_device.mac,
+ self.name,
+ self._electra_ac_device.__dict__,
+ )
+ except ElectraApiError as exp:
+ self._consecutive_failures += 1
+ _LOGGER.warning(
+ "Failed to get %s state: %s (try #%i since last success), keeping old state",
+ self.name,
+ exp,
+ self._consecutive_failures,
+ )
+
+ if self._consecutive_failures >= CONSECUTIVE_FAILURE_THRESHOLD:
+ raise HomeAssistantError(
+ f"Failed to get {self.name} state: {exp} for the {self._consecutive_failures} time",
+ ) from ElectraApiError
+
+ self._consecutive_failures = 0
+ self._update_device_attrs()
+
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
+ """Set AC fan mode."""
+ mode = FAN_HASS_TO_ELECTRA[fan_mode]
+ self._electra_ac_device.set_fan_speed(mode)
+ await self._async_operate_electra_ac()
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set hvac mode."""
+
+ if hvac_mode == HVACMode.OFF:
+ self._electra_ac_device.turn_off()
+ else:
+ self._electra_ac_device.set_mode(HVAC_MODE_HASS_TO_ELECTRA[hvac_mode])
+ self._electra_ac_device.turn_on()
+
+ await self._async_operate_electra_ac()
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+
+ if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
+ raise ValueError("No target temperature provided")
+
+ self._electra_ac_device.set_temperature(temperature)
+ await self._async_operate_electra_ac()
+
+ def _update_device_attrs(self) -> None:
+ self._attr_fan_mode = FAN_ELECTRA_TO_HASS[
+ self._electra_ac_device.get_fan_speed()
+ ]
+ self._attr_current_temperature = (
+ self._electra_ac_device.get_sensor_temperature()
+ )
+ self._attr_target_temperature = self._electra_ac_device.get_temperature()
+
+ self._attr_hvac_mode = (
+ HVACMode.OFF
+ if not self._electra_ac_device.is_on()
+ else HVAC_MODE_ELECTRA_TO_HASS[self._electra_ac_device.get_mode()]
+ )
+
+ if (
+ self._electra_ac_device.is_horizontal_swing()
+ and self._electra_ac_device.is_vertical_swing()
+ ):
+ self._attr_swing_mode = SWING_BOTH
+ elif self._electra_ac_device.is_horizontal_swing():
+ self._attr_swing_mode = SWING_HORIZONTAL
+ elif self._electra_ac_device.is_vertical_swing():
+ self._attr_swing_mode = SWING_VERTICAL
+ else:
+ self._attr_swing_mode = SWING_OFF
+
+ self._attr_preset_mode = (
+ PRESET_SHABAT if self._electra_ac_device.get_shabat_mode() else PRESET_NONE
+ )
+
+ async def async_set_swing_mode(self, swing_mode: str) -> None:
+ """Set AC swing mdde."""
+ if swing_mode == SWING_BOTH:
+ self._electra_ac_device.set_horizontal_swing(True)
+ self._electra_ac_device.set_vertical_swing(True)
+
+ elif swing_mode == SWING_VERTICAL:
+ self._electra_ac_device.set_horizontal_swing(False)
+ self._electra_ac_device.set_vertical_swing(True)
+
+ elif swing_mode == SWING_HORIZONTAL:
+ self._electra_ac_device.set_horizontal_swing(True)
+ self._electra_ac_device.set_vertical_swing(False)
+ else:
+ self._electra_ac_device.set_horizontal_swing(False)
+ self._electra_ac_device.set_vertical_swing(False)
+
+ await self._async_operate_electra_ac()
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set Preset mode."""
+ if preset_mode == PRESET_SHABAT:
+ self._electra_ac_device.set_shabat_mode(True)
+ else:
+ self._electra_ac_device.set_shabat_mode(False)
+
+ await self._async_operate_electra_ac()
+
+ async def _async_operate_electra_ac(self) -> None:
+ """Send HVAC parameters to API."""
+
+ try:
+ resp = await self._api.set_state(self._electra_ac_device)
+ except ElectraApiError as exp:
+ raise HomeAssistantError(
+ f"Error communicating with Electra API: {exp}"
+ ) from exp
+
+ if not (
+ resp[Attributes.STATUS] == STATUS_SUCCESS
+ and resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS
+ ):
+ self._async_write_ha_state()
+ raise HomeAssistantError(f"Failed to update {self.name}, error: {resp}")
+
+ self._update_device_attrs()
+ self._last_state_update = int(time.time())
+ self._async_write_ha_state()
diff --git a/homeassistant/components/electrasmart/config_flow.py b/homeassistant/components/electrasmart/config_flow.py
new file mode 100644
index 000000000000..946a9f2854d0
--- /dev/null
+++ b/homeassistant/components/electrasmart/config_flow.py
@@ -0,0 +1,158 @@
+"""Config flow for Electra Air Conditioner integration."""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError
+from electrasmart.api.utils import generate_imei
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_TOKEN
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_IMEI, CONF_OTP, CONF_PHONE_NUMBER, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Electra Air Conditioner."""
+
+ VERSION = 1
+
+ def __init__(self) -> None:
+ """Device settings."""
+ self._phone_number: str | None = None
+ self._description_placeholders = None
+ self._otp: str | None = None
+ self._imei: str | None = None
+ self._token: str | None = None
+ self._api: ElectraAPI | None = None
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle the initial step."""
+
+ if not self._api:
+ self._api = ElectraAPI(async_get_clientsession(self.hass))
+
+ errors: dict[str, Any] = {}
+
+ if user_input is None:
+ return self._show_setup_form(user_input, errors, "user")
+
+ return await self._validate_phone_number(user_input)
+
+ def _show_setup_form(
+ self,
+ user_input: dict[str, str] | None = None,
+ errors: dict[str, str] | None = None,
+ step_id: str = "user",
+ ) -> FlowResult:
+ """Show the setup form to the user."""
+ if user_input is None:
+ user_input = {}
+
+ if step_id == "user":
+ schema = {
+ vol.Required(
+ CONF_PHONE_NUMBER, default=user_input.get(CONF_PHONE_NUMBER, "")
+ ): str
+ }
+ else:
+ schema = {vol.Required(CONF_OTP, default=user_input.get(CONF_OTP, "")): str}
+
+ return self.async_show_form(
+ step_id=step_id,
+ data_schema=vol.Schema(schema),
+ errors=errors or {},
+ description_placeholders=self._description_placeholders,
+ )
+
+ async def _validate_phone_number(self, user_input: dict[str, str]) -> FlowResult:
+ """Check if config is valid and create entry if so."""
+
+ self._phone_number = user_input[CONF_PHONE_NUMBER]
+ self._imei = generate_imei()
+
+ # Check if already configured
+ if self.unique_id is None:
+ await self.async_set_unique_id(self._phone_number)
+ self._abort_if_unique_id_configured()
+
+ assert isinstance(self._api, ElectraAPI)
+
+ try:
+ resp = await self._api.generate_new_token(self._phone_number, self._imei)
+ except ElectraApiError as exp:
+ _LOGGER.error("Failed to connect to API: %s", exp)
+ return self._show_setup_form(user_input, {"base": "cannot_connect"}, "user")
+
+ if resp[Attributes.STATUS] == STATUS_SUCCESS:
+ if resp[Attributes.DATA][Attributes.RES] != STATUS_SUCCESS:
+ return self._show_setup_form(
+ user_input, {CONF_PHONE_NUMBER: "invalid_phone_number"}, "user"
+ )
+
+ return await self.async_step_one_time_password()
+
+ async def _validate_one_time_password(
+ self, user_input: dict[str, str]
+ ) -> FlowResult:
+ self._otp = user_input[CONF_OTP]
+
+ assert isinstance(self._api, ElectraAPI)
+ assert isinstance(self._imei, str)
+ assert isinstance(self._phone_number, str)
+ assert isinstance(self._otp, str)
+
+ try:
+ resp = await self._api.validate_one_time_password(
+ self._otp, self._imei, self._phone_number
+ )
+ except ElectraApiError as exp:
+ _LOGGER.error("Failed to connect to API: %s", exp)
+ return self._show_setup_form(
+ user_input, {"base": "cannot_connect"}, CONF_OTP
+ )
+
+ if resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS:
+ self._token = resp[Attributes.DATA][Attributes.TOKEN]
+
+ data = {
+ CONF_TOKEN: self._token,
+ CONF_IMEI: self._imei,
+ CONF_PHONE_NUMBER: self._phone_number,
+ }
+ return self.async_create_entry(title=self._phone_number, data=data)
+ return self._show_setup_form(user_input, {CONF_OTP: "invalid_auth"}, CONF_OTP)
+
+ async def async_step_one_time_password(
+ self,
+ user_input: dict[str, Any] | None = None,
+ errors: dict[str, str] | None = None,
+ ) -> FlowResult:
+ """Ask the verification code to the user."""
+ if errors is None:
+ errors = {}
+
+ if user_input is None:
+ return await self._show_otp_form(errors)
+
+ return await self._validate_one_time_password(user_input)
+
+ async def _show_otp_form(
+ self,
+ errors: dict[str, str] | None = None,
+ ) -> FlowResult:
+ """Show the verification_code form to the user."""
+
+ return self.async_show_form(
+ step_id=CONF_OTP,
+ data_schema=vol.Schema({vol.Required(CONF_OTP): str}),
+ errors=errors or {},
+ )
diff --git a/homeassistant/components/electrasmart/const.py b/homeassistant/components/electrasmart/const.py
new file mode 100644
index 000000000000..1a48dd3c463d
--- /dev/null
+++ b/homeassistant/components/electrasmart/const.py
@@ -0,0 +1,13 @@
+"""Constants for the Electra Air Conditioner integration."""
+
+DOMAIN = "electrasmart"
+
+CONF_PHONE_NUMBER = "phone_number"
+CONF_OTP = "one_time_password"
+CONF_IMEI = "imei"
+SCAN_INTERVAL_SEC = 30
+API_DELAY = 5
+CONSECUTIVE_FAILURE_THRESHOLD = 4
+UNAVAILABLE_THRESH_SEC = 120
+PRESET_NONE = "None"
+PRESET_SHABAT = "Shabat"
diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json
new file mode 100644
index 000000000000..a2a3f928eeb4
--- /dev/null
+++ b/homeassistant/components/electrasmart/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "electrasmart",
+ "name": "Electra Smart",
+ "codeowners": ["@jafar-atili"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/electrasmart",
+ "iot_class": "cloud_polling",
+ "requirements": ["pyelectra==1.2.0"]
+}
diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json
new file mode 100644
index 000000000000..06c7dfd6beda
--- /dev/null
+++ b/homeassistant/components/electrasmart/strings.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "phone_number": "Phone Number"
+ }
+ },
+ "one_time_password": {
+ "data": {
+ "one_time_password": "One Time Password"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "invalid_phone_number": "Either wrong phone number or unregistered user"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
index c193f4894ba5..c20621ce60f0 100644
--- a/homeassistant/components/elkm1/__init__.py
+++ b/homeassistant/components/elkm1/__init__.py
@@ -188,7 +188,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
)
hass.async_create_background_task(_async_discovery(), "elkm1 setup discovery")
- async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL)
+ async_track_time_interval(
+ hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True
+ )
if DOMAIN not in hass_config:
return True
diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py
index 86102242b314..1ba93da716c5 100644
--- a/homeassistant/components/emulated_hue/__init__.py
+++ b/homeassistant/components/emulated_hue/__init__.py
@@ -138,16 +138,16 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
app._on_startup.freeze()
await app.startup()
- DescriptionXmlView(config).register(app, app.router)
- HueUsernameView().register(app, app.router)
- HueConfigView(config).register(app, app.router)
- HueUnauthorizedUser().register(app, app.router)
- HueAllLightsStateView(config).register(app, app.router)
- HueOneLightStateView(config).register(app, app.router)
- HueOneLightChangeView(config).register(app, app.router)
- HueAllGroupsStateView(config).register(app, app.router)
- HueGroupView(config).register(app, app.router)
- HueFullStateView(config).register(app, app.router)
+ DescriptionXmlView(config).register(hass, app, app.router)
+ HueUsernameView().register(hass, app, app.router)
+ HueConfigView(config).register(hass, app, app.router)
+ HueUnauthorizedUser().register(hass, app, app.router)
+ HueAllLightsStateView(config).register(hass, app, app.router)
+ HueOneLightStateView(config).register(hass, app, app.router)
+ HueOneLightChangeView(config).register(hass, app, app.router)
+ HueAllGroupsStateView(config).register(hass, app, app.router)
+ HueGroupView(config).register(hass, app, app.router)
+ HueFullStateView(config).register(hass, app, app.router)
async def _start(event: Event) -> None:
"""Start the bridge."""
diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py
index 41598b39b729..7f86b2458cbd 100644
--- a/homeassistant/components/energy/__init__.py
+++ b/homeassistant/components/energy/__init__.py
@@ -4,13 +4,15 @@ from __future__ import annotations
from homeassistant.components import frontend
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import discovery
+from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
from .const import DOMAIN
from .data import async_get_manager
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def is_configured(hass: HomeAssistant) -> bool:
"""Return a boolean to indicate if energy is configured."""
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index b2b29760e5e2..7518b163f3c1 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -226,6 +226,8 @@ class EnergyCostSensor(SensorEntity):
"""
_attr_entity_registry_visible_default = False
+ _attr_should_poll = False
+
_wrong_state_class_reported = False
_wrong_unit_reported = False
diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py
index 232f14e8f8d3..a30509a3840a 100644
--- a/homeassistant/components/energyzero/coordinator.py
+++ b/homeassistant/components/energyzero/coordinator.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR
@@ -47,7 +47,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
async def _async_update_data(self) -> EnergyZeroData:
"""Fetch data from EnergyZero."""
- today = dt.now().date()
+ today = dt_util.now().date()
gas_today = None
energy_tomorrow = None
@@ -62,7 +62,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
except EnergyZeroNoDataError:
LOGGER.debug("No data for gas prices for EnergyZero integration")
# Energy for tomorrow only after 14:00 UTC
- if dt.utcnow().hour >= THRESHOLD_HOUR:
+ if dt_util.utcnow().hour >= THRESHOLD_HOUR:
tomorrow = today + timedelta(days=1)
try:
energy_tomorrow = await self.energyzero.energy_prices(
diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py
index e18542241da9..e7f94647941a 100644
--- a/homeassistant/components/enocean/binary_sensor.py
+++ b/homeassistant/components/enocean/binary_sensor.py
@@ -7,6 +7,7 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
PLATFORM_SCHEMA,
+ BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME
@@ -37,9 +38,9 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Binary Sensor platform for EnOcean."""
- dev_id = config.get(CONF_ID)
- dev_name = config.get(CONF_NAME)
- device_class = config.get(CONF_DEVICE_CLASS)
+ dev_id: list[int] = config[CONF_ID]
+ dev_name: str = config[CONF_NAME]
+ device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)])
@@ -52,7 +53,12 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity):
- F6-02-02 (Light and Blind Control - Application Style 1)
"""
- def __init__(self, dev_id, dev_name, device_class):
+ def __init__(
+ self,
+ dev_id: list[int],
+ dev_name: str,
+ device_class: BinarySensorDeviceClass | None,
+ ) -> None:
"""Initialize the EnOcean binary sensor."""
super().__init__(dev_id, dev_name)
self._device_class = device_class
diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py
index 0bd084742b54..1c98b4dd2341 100644
--- a/homeassistant/components/enocean/device.py
+++ b/homeassistant/components/enocean/device.py
@@ -11,7 +11,7 @@ from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
class EnOceanEntity(Entity):
"""Parent class for all entities associated with the EnOcean component."""
- def __init__(self, dev_id, dev_name="EnOcean device"):
+ def __init__(self, dev_id: list[int], dev_name: str) -> None:
"""Initialize the device."""
self.dev_id = dev_id
self.dev_name = dev_name
diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py
index 479723bd0516..e2a194af8ba0 100644
--- a/homeassistant/components/enocean/light.py
+++ b/homeassistant/components/enocean/light.py
@@ -41,9 +41,9 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the EnOcean light platform."""
- sender_id = config.get(CONF_SENDER_ID)
- dev_name = config.get(CONF_NAME)
- dev_id = config.get(CONF_ID)
+ sender_id: list[int] = config[CONF_SENDER_ID]
+ dev_name: str = config[CONF_NAME]
+ dev_id: list[int] = config[CONF_ID]
add_entities([EnOceanLight(sender_id, dev_id, dev_name)])
@@ -54,7 +54,7 @@ class EnOceanLight(EnOceanEntity, LightEntity):
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
- def __init__(self, sender_id, dev_id, dev_name):
+ def __init__(self, sender_id: list[int], dev_id: list[int], dev_name: str) -> None:
"""Initialize the EnOcean light source."""
super().__init__(dev_id, dev_name)
self._on_state = False
diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py
index ae2110b31740..5d1c00277915 100644
--- a/homeassistant/components/enocean/sensor.py
+++ b/homeassistant/components/enocean/sensor.py
@@ -117,16 +117,16 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an EnOcean sensor device."""
- dev_id = config[CONF_ID]
- dev_name = config[CONF_NAME]
- sensor_type = config[CONF_DEVICE_CLASS]
+ dev_id: list[int] = config[CONF_ID]
+ dev_name: str = config[CONF_NAME]
+ sensor_type: str = config[CONF_DEVICE_CLASS]
entities: list[EnOceanSensor] = []
if sensor_type == SENSOR_TYPE_TEMPERATURE:
- temp_min = config[CONF_MIN_TEMP]
- temp_max = config[CONF_MAX_TEMP]
- range_from = config[CONF_RANGE_FROM]
- range_to = config[CONF_RANGE_TO]
+ temp_min: int = config[CONF_MIN_TEMP]
+ temp_max: int = config[CONF_MAX_TEMP]
+ range_from: int = config[CONF_RANGE_FROM]
+ range_to: int = config[CONF_RANGE_TO]
entities = [
EnOceanTemperatureSensor(
dev_id,
@@ -151,11 +151,15 @@ def setup_platform(
add_entities(entities)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity):
"""Representation of an EnOcean sensor device such as a power meter."""
def __init__(
- self, dev_id, dev_name, description: EnOceanSensorEntityDescription
+ self,
+ dev_id: list[int],
+ dev_name: str,
+ description: EnOceanSensorEntityDescription,
) -> None:
"""Initialize the EnOcean sensor device."""
super().__init__(dev_id, dev_name)
@@ -177,6 +181,7 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity):
"""Update the internal state of the sensor."""
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnOceanPowerSensor(EnOceanSensor):
"""Representation of an EnOcean power sensor.
@@ -197,6 +202,7 @@ class EnOceanPowerSensor(EnOceanSensor):
self.schedule_update_ha_state()
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnOceanTemperatureSensor(EnOceanSensor):
"""Representation of an EnOcean temperature sensor device.
@@ -217,14 +223,14 @@ class EnOceanTemperatureSensor(EnOceanSensor):
def __init__(
self,
- dev_id,
- dev_name,
+ dev_id: list[int],
+ dev_name: str,
description: EnOceanSensorEntityDescription,
*,
- scale_min,
- scale_max,
- range_from,
- range_to,
+ scale_min: int,
+ scale_max: int,
+ range_from: int,
+ range_to: int,
) -> None:
"""Initialize the EnOcean temperature sensor device."""
super().__init__(dev_id, dev_name, description)
@@ -246,6 +252,7 @@ class EnOceanTemperatureSensor(EnOceanSensor):
self.schedule_update_ha_state()
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnOceanHumiditySensor(EnOceanSensor):
"""Representation of an EnOcean humidity sensor device.
@@ -264,6 +271,7 @@ class EnOceanHumiditySensor(EnOceanSensor):
self.schedule_update_ha_state()
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnOceanWindowHandle(EnOceanSensor):
"""Representation of an EnOcean window handle device.
diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py
index 11ca8a2a625e..c69821c83727 100644
--- a/homeassistant/components/enocean/switch.py
+++ b/homeassistant/components/enocean/switch.py
@@ -65,9 +65,9 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the EnOcean switch platform."""
- channel = config.get(CONF_CHANNEL)
- dev_id = config.get(CONF_ID)
- dev_name = config.get(CONF_NAME)
+ channel: int = config[CONF_CHANNEL]
+ dev_id: list[int] = config[CONF_ID]
+ dev_name: str = config[CONF_NAME]
_migrate_to_new_unique_id(hass, dev_id, channel)
async_add_entities([EnOceanSwitch(dev_id, dev_name, channel)])
@@ -76,7 +76,7 @@ async def async_setup_platform(
class EnOceanSwitch(EnOceanEntity, SwitchEntity):
"""Representation of an EnOcean switch device."""
- def __init__(self, dev_id, dev_name, channel):
+ def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None:
"""Initialize the EnOcean switch device."""
super().__init__(dev_id, dev_name)
self._light = None
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
index 74bf9c8ca54b..32ccfa901dbb 100644
--- a/homeassistant/components/environment_canada/weather.py
+++ b/homeassistant/components/environment_canada/weather.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import device_info
from .const import DOMAIN
@@ -169,7 +169,7 @@ def get_forecast(ec_data, hourly):
return None
today = {
- ATTR_FORECAST_TIME: dt.now().isoformat(),
+ ATTR_FORECAST_TIME: dt_util.now().isoformat(),
ATTR_FORECAST_CONDITION: icon_code_to_condition(
int(half_days[0]["icon_code"])
),
@@ -201,7 +201,7 @@ def get_forecast(ec_data, hourly):
forecast_array.append(
{
ATTR_FORECAST_TIME: (
- dt.now() + datetime.timedelta(days=day)
+ dt_util.now() + datetime.timedelta(days=day)
).isoformat(),
ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]),
ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]),
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index a68dd562af14..f95763d3a6c4 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -302,7 +302,7 @@ async def async_setup_entry( # noqa: C901
voice_assistant_udp_server.close()
voice_assistant_udp_server = None
- async def _handle_pipeline_start() -> int | None:
+ async def _handle_pipeline_start(conversation_id: str, use_vad: bool) -> int | None:
"""Start a voice assistant pipeline."""
nonlocal voice_assistant_udp_server
@@ -315,7 +315,10 @@ async def async_setup_entry( # noqa: C901
port = await voice_assistant_udp_server.start_server()
hass.async_create_background_task(
- voice_assistant_udp_server.run_pipeline(),
+ voice_assistant_udp_server.run_pipeline(
+ conversation_id=conversation_id or None,
+ use_vad=use_vad,
+ ),
"esphome.voice_assistant_udp_server.run_pipeline",
)
entry_data.async_set_assist_pipeline_state(True)
diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py
index 7439f8946f6e..a8332f8d0400 100644
--- a/homeassistant/components/esphome/dashboard.py
+++ b/homeassistant/components/esphome/dashboard.py
@@ -10,7 +10,8 @@ from awesomeversion import AwesomeVersion
from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -31,13 +32,13 @@ async def async_set_dashboard_info(
"""Set the dashboard info."""
url = f"http://{host}:{port}"
- # Do nothing if we already have this data.
- if (
- (cur_dashboard := hass.data.get(KEY_DASHBOARD))
- and cur_dashboard.addon_slug == addon_slug
- and cur_dashboard.url == url
- ):
- return
+ if cur_dashboard := async_get_dashboard(hass):
+ if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url:
+ # Do nothing if we already have this data.
+ return
+ # Clear and make way for new dashboard
+ await cur_dashboard.async_shutdown()
+ del hass.data[KEY_DASHBOARD]
dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass))
try:
@@ -48,6 +49,11 @@ async def async_set_dashboard_info(
hass.data[KEY_DASHBOARD] = dashboard
+ async def on_hass_stop(_: Event) -> None:
+ await dashboard.async_shutdown()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
+
reloads = [
hass.config_entries.async_reload(entry.entry_id)
for entry in hass.config_entries.async_entries(DOMAIN)
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index 7ce195d68fce..225ae3961e8c 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -266,8 +266,14 @@ class RuntimeEntryData:
)
stale_state.discard(subscription_key)
current_state_by_type[key] = state
- if subscription_key in self.state_subscriptions:
- self.state_subscriptions[subscription_key]()
+ if subscription := self.state_subscriptions.get(subscription_key):
+ try:
+ subscription()
+ except Exception as ex: # pylint: disable=broad-except
+ # If we allow this exception to raise it will
+ # make it all the way to data_received in aioesphomeapi
+ # which will cause the connection to be closed.
+ _LOGGER.exception("Error while calling subscription: %s", ex)
@callback
def async_update_device_state(self, hass: HomeAssistant) -> None:
diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py
index 880d94a5f551..5e16f2476bb2 100644
--- a/homeassistant/components/esphome/light.py
+++ b/homeassistant/components/esphome/light.py
@@ -122,7 +122,7 @@ def _filter_color_modes(
Excluding all values that don't have the requested features.
"""
- return [mode for mode in supported if mode & features]
+ return [mode for mode in supported if (mode & features) == features]
class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index 0e9715038f06..c6e430d78451 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -15,7 +15,7 @@
"iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol"],
"requirements": [
- "aioesphomeapi==13.7.4",
+ "aioesphomeapi==13.9.0",
"bluetooth-data-tools==0.4.0",
"esphome-dashboard-api==1.2.3"
],
diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py
index 25a0bfaff7f5..7a1234341bea 100644
--- a/homeassistant/components/esphome/sensor.py
+++ b/homeassistant/components/esphome/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
@@ -81,7 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
if self._state.missing_state:
return None
if self.device_class == SensorDeviceClass.TIMESTAMP:
- return dt.utc_from_timestamp(self._state.state)
+ return dt_util.utc_from_timestamp(self._state.state)
return f"{self._state.state:.{self._static_info.accuracy_decimals}f}"
@property
diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py
index aaa2dc80a78c..efb4162ae1a6 100644
--- a/homeassistant/components/esphome/voice_assistant.py
+++ b/homeassistant/components/esphome/voice_assistant.py
@@ -2,7 +2,8 @@
from __future__ import annotations
import asyncio
-from collections.abc import AsyncIterable, Callable
+from collections import deque
+from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence
import logging
import socket
from typing import cast
@@ -14,9 +15,11 @@ from homeassistant.components import stt, tts
from homeassistant.components.assist_pipeline import (
PipelineEvent,
PipelineEventType,
+ PipelineNotFound,
async_pipeline_from_audio_stream,
select as pipeline_select,
)
+from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.core import Context, HomeAssistant, callback
@@ -50,7 +53,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
"""Receive UDP packets and forward them to the voice assistant."""
started = False
- queue: asyncio.Queue[bytes] | None = None
+ stopped = False
transport: asyncio.DatagramTransport | None = None
remote_addr: tuple[str, int] | None = None
@@ -60,6 +63,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
entry_data: RuntimeEntryData,
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
handle_finished: Callable[[], None],
+ audio_timeout: float = 2.0,
) -> None:
"""Initialize UDP receiver."""
self.context = Context()
@@ -68,10 +72,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
assert entry_data.device_info is not None
self.device_info = entry_data.device_info
- self.queue = asyncio.Queue()
+ self.queue: asyncio.Queue[bytes] = asyncio.Queue()
self.handle_event = handle_event
self.handle_finished = handle_finished
self._tts_done = asyncio.Event()
+ self.audio_timeout = audio_timeout
async def start_server(self) -> int:
"""Start accepting connections."""
@@ -80,7 +85,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
"""Accept connection."""
if self.started:
raise RuntimeError("Can only start once")
- if self.queue is None:
+ if self.stopped:
raise RuntimeError("No longer accepting connections")
self.started = True
@@ -105,12 +110,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
@callback
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle incoming UDP packet."""
- if not self.started:
+ if not self.started or self.stopped:
return
if self.remote_addr is None:
self.remote_addr = addr
- if self.queue is not None:
- self.queue.put_nowait(data)
+ self.queue.put_nowait(data)
def error_received(self, exc: Exception) -> None:
"""Handle when a send or receive operation raises an OSError.
@@ -123,21 +127,21 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
@callback
def stop(self) -> None:
"""Stop the receiver."""
- if self.queue is not None:
- self.queue.put_nowait(b"")
+ self.queue.put_nowait(b"")
self.started = False
+ self.stopped = True
def close(self) -> None:
"""Close the receiver."""
- if self.queue is not None:
- self.queue = None
+ self.started = False
+ self.stopped = True
if self.transport is not None:
self.transport.close()
async def _iterate_packets(self) -> AsyncIterable[bytes]:
"""Iterate over incoming packets."""
- if self.queue is None:
- raise RuntimeError("Already stopped")
+ if not self.started or self.stopped:
+ raise RuntimeError("Not running")
while data := await self.queue.get():
yield data
@@ -152,9 +156,15 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
return
data_to_send = None
+ error = False
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
+ elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
+ assert event.data is not None
+ data_to_send = {
+ "conversation_id": event.data["intent_output"]["conversation_id"] or "",
+ }
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
assert event.data is not None
data_to_send = {"text": event.data["tts_input"]}
@@ -177,19 +187,132 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
"code": event.data["code"],
"message": event.data["message"],
}
- self.handle_finished()
+ self._tts_done.set()
+ error = True
self.handle_event(event_type, data_to_send)
+ if error:
+ self.handle_finished()
+
+ async def _wait_for_speech(
+ self,
+ segmenter: VoiceCommandSegmenter,
+ chunk_buffer: MutableSequence[bytes],
+ ) -> bool:
+ """Buffer audio chunks until speech is detected.
+
+ Raises asyncio.TimeoutError if no audio data is retrievable from the queue (device stops sending packets / networking issue).
+
+ Returns True if speech was detected
+ Returns False if the connection was stopped gracefully (b"" put onto the queue).
+ """
+ # Timeout if no audio comes in for a while.
+ async with async_timeout.timeout(self.audio_timeout):
+ chunk = await self.queue.get()
+
+ while chunk:
+ segmenter.process(chunk)
+ # Buffer the data we have taken from the queue
+ chunk_buffer.append(chunk)
+ if segmenter.in_command:
+ return True
+
+ async with async_timeout.timeout(self.audio_timeout):
+ chunk = await self.queue.get()
+
+ # If chunk is falsey, `stop()` was called
+ return False
+
+ async def _segment_audio(
+ self,
+ segmenter: VoiceCommandSegmenter,
+ chunk_buffer: Sequence[bytes],
+ ) -> AsyncIterable[bytes]:
+ """Yield audio chunks until voice command has finished.
+
+ Raises asyncio.TimeoutError if no audio data is retrievable from the queue.
+ """
+ # Buffered chunks first
+ for buffered_chunk in chunk_buffer:
+ yield buffered_chunk
+
+ # Timeout if no audio comes in for a while.
+ async with async_timeout.timeout(self.audio_timeout):
+ chunk = await self.queue.get()
+
+ while chunk:
+ if not segmenter.process(chunk):
+ # Voice command is finished
+ break
+
+ yield chunk
+
+ async with async_timeout.timeout(self.audio_timeout):
+ chunk = await self.queue.get()
+
+ async def _iterate_packets_with_vad(
+ self, pipeline_timeout: float
+ ) -> Callable[[], AsyncIterable[bytes]] | None:
+ segmenter = VoiceCommandSegmenter()
+ chunk_buffer: deque[bytes] = deque(maxlen=100)
+ try:
+ async with async_timeout.timeout(pipeline_timeout):
+ speech_detected = await self._wait_for_speech(segmenter, chunk_buffer)
+ if not speech_detected:
+ _LOGGER.debug(
+ "Device stopped sending audio before speech was detected"
+ )
+ self.handle_finished()
+ return None
+ except asyncio.TimeoutError:
+ self.handle_event(
+ VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
+ {
+ "code": "speech-timeout",
+ "message": "Timed out waiting for speech",
+ },
+ )
+ self.handle_finished()
+ return None
+
+ async def _stream_packets() -> AsyncIterable[bytes]:
+ try:
+ async for chunk in self._segment_audio(segmenter, chunk_buffer):
+ yield chunk
+ except asyncio.TimeoutError:
+ self.handle_event(
+ VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
+ {
+ "code": "speech-timeout",
+ "message": "No speech detected",
+ },
+ )
+ self.handle_finished()
+
+ return _stream_packets
async def run_pipeline(
self,
+ conversation_id: str | None,
+ use_vad: bool = False,
pipeline_timeout: float = 30.0,
) -> None:
"""Run the Voice Assistant pipeline."""
+
+ tts_audio_output = (
+ "raw" if self.device_info.voice_assistant_version >= 2 else "mp3"
+ )
+
+ if use_vad:
+ stt_stream = await self._iterate_packets_with_vad(pipeline_timeout)
+ # Error or timeout occurred and was handled already
+ if stt_stream is None:
+ return
+ else:
+ stt_stream = self._iterate_packets
+
+ _LOGGER.debug("Starting pipeline")
try:
- tts_audio_output = (
- "raw" if self.device_info.voice_assistant_version >= 2 else "mp3"
- )
async with async_timeout.timeout(pipeline_timeout):
await async_pipeline_from_audio_stream(
self.hass,
@@ -203,10 +326,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
- stt_stream=self._iterate_packets(),
+ stt_stream=stt_stream(),
pipeline_id=pipeline_select.get_chosen_pipeline(
self.hass, DOMAIN, self.device_info.mac_address
),
+ conversation_id=conversation_id,
tts_audio_output=tts_audio_output,
)
@@ -214,7 +338,23 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
await self._tts_done.wait()
_LOGGER.debug("Pipeline finished")
+ except PipelineNotFound:
+ self.handle_event(
+ VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
+ {
+ "code": "pipeline not found",
+ "message": "Selected pipeline timeout",
+ },
+ )
+ _LOGGER.warning("Pipeline not found")
except asyncio.TimeoutError:
+ self.handle_event(
+ VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
+ {
+ "code": "pipeline-timeout",
+ "message": "Pipeline timeout",
+ },
+ )
_LOGGER.warning("Pipeline timeout")
finally:
self.handle_finished()
diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py
index e57b83687a62..d7c69dec165a 100644
--- a/homeassistant/components/eufylife_ble/sensor.py
+++ b/homeassistant/components/eufylife_ble/sensor.py
@@ -111,6 +111,7 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity):
return UnitOfMass.KILOGRAMS
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity):
"""Representation of an EufyLife weight sensor."""
@@ -171,6 +172,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity):
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity):
"""Representation of an EufyLife heart rate sensor."""
diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py
index 489ff97eb4a6..2966c339f957 100644
--- a/homeassistant/components/ezviz/__init__.py
+++ b/homeassistant/components/ezviz/__init__.py
@@ -35,8 +35,10 @@ PLATFORMS_BY_TYPE: dict[str, list] = {
ATTR_TYPE_CLOUD: [
Platform.BINARY_SENSOR,
Platform.CAMERA,
+ Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.UPDATE,
],
}
diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py
index bab6fa5ca979..77e95fa221d0 100644
--- a/homeassistant/components/ezviz/binary_sensor.py
+++ b/homeassistant/components/ezviz/binary_sensor.py
@@ -25,10 +25,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
key="alarm_schedules_enabled"
),
"encrypted": BinarySensorEntityDescription(key="encrypted"),
- "upgrade_available": BinarySensorEntityDescription(
- key="upgrade_available",
- device_class=BinarySensorDeviceClass.UPDATE,
- ),
}
diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py
index 0456e7ade9e6..57be995a4892 100644
--- a/homeassistant/components/ezviz/camera.py
+++ b/homeassistant/components/ezviz/camera.py
@@ -17,7 +17,11 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, discovery_flow
+from homeassistant.helpers import (
+ config_validation as cv,
+ discovery_flow,
+ issue_registry as ir,
+)
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
@@ -303,3 +307,13 @@ class EzvizCamera(EzvizEntity, Camera):
)
except (HTTPError, PyEzvizError) as err:
raise PyEzvizError("Cannot set detection sensitivity level") from err
+
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ "service_depreciation_detection_sensibility",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="service_depreciation_detection_sensibility",
+ )
diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json
index 5dfeae4bae9a..219f4c87d13b 100644
--- a/homeassistant/components/ezviz/manifest.json
+++ b/homeassistant/components/ezviz/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/ezviz",
"iot_class": "cloud_polling",
"loggers": ["paho_mqtt", "pyezviz"],
- "requirements": ["pyezviz==0.2.0.9"]
+ "requirements": ["pyezviz==0.2.0.12"]
}
diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py
new file mode 100644
index 000000000000..849bf2c400b6
--- /dev/null
+++ b/homeassistant/components/ezviz/number.py
@@ -0,0 +1,98 @@
+"""Support for EZVIZ number controls."""
+from __future__ import annotations
+
+from pyezviz.constants import DeviceCatagories
+from pyezviz.exceptions import HTTPError, PyEzvizError
+
+from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DATA_COORDINATOR, DOMAIN
+from .coordinator import EzvizDataUpdateCoordinator
+from .entity import EzvizEntity
+
+PARALLEL_UPDATES = 1
+
+NUMBER_TYPES = NumberEntityDescription(
+ key="detection_sensibility",
+ name="Detection sensitivity",
+ icon="mdi:eye",
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=0,
+ native_step=1,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up EZVIZ sensors based on a config entry."""
+ coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
+ DATA_COORDINATOR
+ ]
+
+ async_add_entities(
+ EzvizSensor(coordinator, camera, sensor, NUMBER_TYPES)
+ for camera in coordinator.data
+ for sensor, value in coordinator.data[camera].items()
+ if sensor in NUMBER_TYPES.key
+ if value
+ )
+
+
+class EzvizSensor(EzvizEntity, NumberEntity):
+ """Representation of a EZVIZ number entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: EzvizDataUpdateCoordinator,
+ serial: str,
+ sensor: str,
+ description: NumberEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, serial)
+ self._sensor_name = sensor
+ self.battery_cam_type = bool(
+ self.data["device_category"]
+ == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
+ )
+ self._attr_unique_id = f"{serial}_{sensor}"
+ self._attr_native_max_value = 100 if self.battery_cam_type else 6
+ self.entity_description = description
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the state of the entity."""
+ try:
+ return float(self.data[self._sensor_name])
+ except ValueError:
+ return None
+
+ def set_native_value(self, value: float) -> None:
+ """Set camera detection sensitivity."""
+ level = int(value)
+ try:
+ if self.battery_cam_type:
+ self.coordinator.ezviz_client.detection_sensibility(
+ self._serial,
+ level,
+ 3,
+ )
+ else:
+ self.coordinator.ezviz_client.detection_sensibility(
+ self._serial,
+ level,
+ 0,
+ )
+
+ except (HTTPError, PyEzvizError) as err:
+ raise HomeAssistantError(
+ f"Cannot set detection sensitivity level on {self.name}"
+ ) from err
diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py
index 8e617aa3b3eb..11412c1fc70d 100644
--- a/homeassistant/components/ezviz/sensor.py
+++ b/homeassistant/components/ezviz/sensor.py
@@ -25,7 +25,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.BATTERY,
),
"alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"),
- "detection_sensibility": SensorEntityDescription(key="detection_sensibility"),
"last_alarm_time": SensorEntityDescription(key="last_alarm_time"),
"Seconds_Last_Trigger": SensorEntityDescription(
key="Seconds_Last_Trigger",
diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json
index 5e258e427057..6f00568cf2b6 100644
--- a/homeassistant/components/ezviz/strings.json
+++ b/homeassistant/components/ezviz/strings.json
@@ -58,5 +58,11 @@
}
}
}
+ },
+ "issues": {
+ "service_depreciation_detection_sensibility": {
+ "title": "Ezviz Detection sensitivity service is being removed",
+ "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.8; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved."
+ }
}
}
diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py
new file mode 100644
index 000000000000..3acc10325147
--- /dev/null
+++ b/homeassistant/components/ezviz/update.py
@@ -0,0 +1,109 @@
+"""Support for EZVIZ sensors."""
+from __future__ import annotations
+
+from typing import Any
+
+from pyezviz import HTTPError, PyEzvizError
+
+from homeassistant.components.update import (
+ UpdateDeviceClass,
+ UpdateEntity,
+ UpdateEntityDescription,
+ UpdateEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DATA_COORDINATOR, DOMAIN
+from .coordinator import EzvizDataUpdateCoordinator
+from .entity import EzvizEntity
+
+PARALLEL_UPDATES = 1
+
+UPDATE_ENTITY_TYPES = UpdateEntityDescription(
+ key="version",
+ name="Firmware update",
+ device_class=UpdateDeviceClass.FIRMWARE,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up EZVIZ sensors based on a config entry."""
+ coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
+ DATA_COORDINATOR
+ ]
+
+ async_add_entities(
+ EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES)
+ for camera in coordinator.data
+ for sensor, value in coordinator.data[camera].items()
+ if sensor in UPDATE_ENTITY_TYPES.key
+ if value
+ )
+
+
+class EzvizUpdateEntity(EzvizEntity, UpdateEntity):
+ """Representation of a EZVIZ Update entity."""
+
+ _attr_has_entity_name = True
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL
+ | UpdateEntityFeature.PROGRESS
+ | UpdateEntityFeature.RELEASE_NOTES
+ )
+
+ def __init__(
+ self,
+ coordinator: EzvizDataUpdateCoordinator,
+ serial: str,
+ sensor: str,
+ description: UpdateEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, serial)
+ self._attr_unique_id = f"{serial}_{sensor}"
+ self.entity_description = description
+
+ @property
+ def installed_version(self) -> str | None:
+ """Version installed and in use."""
+ return self.data["version"]
+
+ @property
+ def in_progress(self) -> bool | int | None:
+ """Update installation progress."""
+ if self.data["upgrade_in_progress"]:
+ return self.data["upgrade_percent"]
+ return False
+
+ @property
+ def latest_version(self) -> str | None:
+ """Latest version available for install."""
+ if self.data["upgrade_available"]:
+ return self.data["latest_firmware_info"]["version"]
+
+ return self.installed_version
+
+ def release_notes(self) -> str | None:
+ """Return full release notes."""
+ if self.data["latest_firmware_info"]:
+ return self.data["latest_firmware_info"].get("desc")
+ return None
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install an update."""
+ try:
+ await self.hass.async_add_executor_job(
+ self.coordinator.ezviz_client.upgrade_device, self._serial
+ )
+
+ except (HTTPError, PyEzvizError) as err:
+ raise HomeAssistantError(
+ f"Failed to update firmware on {self.name}"
+ ) from err
diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json
index b16d6da6df56..b69068d3d64e 100644
--- a/homeassistant/components/fan/strings.json
+++ b/homeassistant/components/fan/strings.json
@@ -52,5 +52,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py
index 4f00dd5a5439..b3d5f66ae8c8 100644
--- a/homeassistant/components/fastdotcom/sensor.py
+++ b/homeassistant/components/fastdotcom/sensor.py
@@ -24,6 +24,7 @@ async def async_setup_platform(
async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])])
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SpeedtestSensor(RestoreEntity, SensorEntity):
"""Implementation of a FAst.com sensor."""
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
index 50404bb96dce..6be0e3c219f6 100644
--- a/homeassistant/components/feedreader/__init__.py
+++ b/homeassistant/components/feedreader/__init__.py
@@ -93,7 +93,12 @@ class FeedManager:
def _init_regular_updates(self, hass: HomeAssistant) -> None:
"""Schedule regular updates at the top of the clock."""
- track_time_interval(hass, lambda now: self._update(), self._scan_interval)
+ track_time_interval(
+ hass,
+ lambda now: self._update(),
+ self._scan_interval,
+ cancel_on_shutdown=True,
+ )
@property
def last_update_successful(self) -> bool:
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 3313e51ce759..dc7be9f1e69d 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -8,7 +8,6 @@ from typing import Any
from pyfibaro.fibaro_client import FibaroClient
from pyfibaro.fibaro_device import DeviceModel
-from pyfibaro.fibaro_scene import SceneModel
from requests.exceptions import HTTPError
from homeassistant.config_entries import ConfigEntry
@@ -278,24 +277,18 @@ class FibaroController:
return self._device_infos[device.parent_fibaro_id]
return DeviceInfo(identifiers={(DOMAIN, self.hub_serial)})
+ def get_room_name(self, room_id: int) -> str | None:
+ """Get the room name by room id."""
+ assert self._room_map
+ room = self._room_map.get(room_id)
+ return room.name if room else None
+
def _read_scenes(self):
scenes = self._client.read_scenes()
for device in scenes:
device.fibaro_controller = self
- if device.room_id == 0:
- room_name = "Unknown"
- else:
- room_name = self._room_map[device.room_id].name
- device.room_name = room_name
- device.friendly_name = f"{room_name} {device.name}"
- device.ha_id = (
- f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
- )
- device.unique_id_str = (
- f"{slugify(self.hub_serial)}.scene.{device.fibaro_id}"
- )
self.fibaro_devices[Platform.SCENE].append(device)
- _LOGGER.debug("%s scene -> %s", device.ha_id, device)
+ _LOGGER.debug("Scene -> %s", device)
def _read_devices(self):
"""Read and process the device list."""
@@ -425,7 +418,7 @@ class FibaroDevice(Entity):
_attr_should_poll = False
- def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None:
+ def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the device."""
self.fibaro_device = fibaro_device
self.controller = fibaro_device.fibaro_controller
@@ -433,8 +426,7 @@ class FibaroDevice(Entity):
self._attr_name = fibaro_device.friendly_name
self._attr_unique_id = fibaro_device.unique_id_str
- if isinstance(fibaro_device, DeviceModel):
- self._attr_device_info = self.controller.get_device_info(fibaro_device)
+ self._attr_device_info = self.controller.get_device_info(fibaro_device)
# propagate hidden attribute set in fibaro home center to HA
if not fibaro_device.visible:
self._attr_entity_registry_visible_default = False
@@ -519,14 +511,18 @@ class FibaroDevice(Entity):
"""Return the state attributes of the device."""
attr = {"fibaro_id": self.fibaro_device.fibaro_id}
- if isinstance(self.fibaro_device, DeviceModel):
- if self.fibaro_device.has_battery_level:
- attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level
- if self.fibaro_device.has_armed:
- attr[ATTR_ARMED] = self.fibaro_device.armed
+ if self.fibaro_device.has_battery_level:
+ attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level
+ if self.fibaro_device.has_armed:
+ attr[ATTR_ARMED] = self.fibaro_device.armed
return attr
+ def update(self) -> None:
+ """Update the available state of the entity."""
+ if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead:
+ self._attr_available = not self.fibaro_device.dead
+
class FibaroConnectFailed(HomeAssistantError):
"""Error to indicate we cannot connect to fibaro home center."""
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
index 14f0a6a162c3..57b3bc99b4f4 100644
--- a/homeassistant/components/fibaro/binary_sensor.py
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -82,6 +82,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity):
def update(self) -> None:
"""Get the latest data and update the state."""
+ super().update()
if self._fibaro_sensor_type == "com.fibaro.accelerometer":
# Accelerator sensors have values for the three axis x, y and z
moving_values = self._get_moving_values()
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
index 577c66125529..6a918f64f86b 100644
--- a/homeassistant/components/fibaro/light.py
+++ b/homeassistant/components/fibaro/light.py
@@ -176,6 +176,7 @@ class FibaroLight(FibaroDevice, LightEntity):
def _update(self):
"""Really update the state."""
+ super().update()
# Brightness handling
if brightness_supported(self.supported_color_modes):
self._attr_brightness = scaleto255(self.fibaro_device.value.int_value())
diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py
index 0fa1337e3e35..503407bc28f2 100644
--- a/homeassistant/components/fibaro/lock.py
+++ b/homeassistant/components/fibaro/lock.py
@@ -52,4 +52,5 @@ class FibaroLock(FibaroDevice, LockEntity):
def update(self) -> None:
"""Update device state."""
+ super().update()
self._attr_is_locked = self.current_binary_state
diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json
index 866be3fba548..4b3721eed15c 100644
--- a/homeassistant/components/fibaro/manifest.json
+++ b/homeassistant/components/fibaro/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyfibaro"],
- "requirements": ["pyfibaro==0.7.0"]
+ "requirements": ["pyfibaro==0.7.1"]
}
diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py
index 0023b8e3fba7..43baa0e4efd4 100644
--- a/homeassistant/components/fibaro/scene.py
+++ b/homeassistant/components/fibaro/scene.py
@@ -11,8 +11,9 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import slugify
-from . import FIBARO_DEVICES, FibaroDevice
+from . import FIBARO_DEVICES, FibaroController
from .const import DOMAIN
@@ -33,18 +34,30 @@ async def async_setup_entry(
)
-class FibaroScene(FibaroDevice, Scene):
+class FibaroScene(Scene):
"""Representation of a Fibaro scene entity."""
- def __init__(self, fibaro_device: SceneModel) -> None:
+ def __init__(self, fibaro_scene: SceneModel) -> None:
"""Initialize the Fibaro scene."""
- super().__init__(fibaro_device)
+ self._fibaro_scene = fibaro_scene
+ controller: FibaroController = fibaro_scene.fibaro_controller
+ room_name = controller.get_room_name(fibaro_scene.room_id)
+ if not room_name:
+ room_name = "Unknown"
+
+ self._attr_name = f"{room_name} {fibaro_scene.name}"
+ self._attr_unique_id = (
+ f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}"
+ )
+ self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id}
+ # propagate hidden attribute set in fibaro home center to HA
+ self._attr_entity_registry_visible_default = fibaro_scene.visible
# All scenes are shown on hub device
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self.controller.hub_serial)}
+ identifiers={(DOMAIN, controller.hub_serial)}
)
def activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
- self.fibaro_device.start()
+ self._fibaro_scene.start()
diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py
index 6bb8291bbbb0..c41c4afe312c 100644
--- a/homeassistant/components/fibaro/sensor.py
+++ b/homeassistant/components/fibaro/sensor.py
@@ -146,6 +146,7 @@ class FibaroSensor(FibaroDevice, SensorEntity):
def update(self) -> None:
"""Update the state."""
+ super().update()
with suppress(TypeError):
self._attr_native_value = self.fibaro_device.value.float_value()
@@ -170,6 +171,7 @@ class FibaroAdditionalSensor(FibaroDevice, SensorEntity):
def update(self) -> None:
"""Update the state."""
+ super().update()
with suppress(KeyError, ValueError):
self._attr_native_value = convert(
self.fibaro_device.properties[self.entity_description.key],
diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py
index d5c6eebeee50..6ca770ab2d1c 100644
--- a/homeassistant/components/fibaro/switch.py
+++ b/homeassistant/components/fibaro/switch.py
@@ -52,4 +52,5 @@ class FibaroSwitch(FibaroDevice, SwitchEntity):
def update(self) -> None:
"""Update device state."""
+ super().update()
self._attr_is_on = self.current_binary_state
diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py
index 73f8465b1df7..8c594f7f85ca 100644
--- a/homeassistant/components/file_upload/__init__.py
+++ b/homeassistant/components/file_upload/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import raise_if_invalid_filename
from homeassistant.util.ulid import ulid_hex
@@ -27,6 +28,8 @@ ONE_MEGABYTE = 1024 * 1024
MAX_SIZE = 100 * ONE_MEGABYTE
TEMP_DIR_NAME = f"home-assistant-{DOMAIN}"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@contextmanager
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
index 9b1e2250a28c..a1470baa4d2f 100644
--- a/homeassistant/components/filter/sensor.py
+++ b/homeassistant/components/filter/sensor.py
@@ -18,10 +18,8 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.components.recorder import get_instance, history
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
- DEVICE_CLASSES as SENSOR_DEVICE_CLASSES,
DOMAIN as SENSOR_DOMAIN,
PLATFORM_SCHEMA,
- STATE_CLASSES as SENSOR_STATE_CLASSES,
SensorDeviceClass,
SensorEntity,
)
@@ -41,6 +39,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.util.decorator import Registry
import homeassistant.util.dt as dt_util
@@ -273,22 +272,15 @@ class SensorFilter(SensorEntity):
self._state = temp_state.state
- if self._attr_icon is None:
- self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON)
+ self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON)
+ self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
+ self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS)
- if (
- self._attr_device_class is None
- and new_state.attributes.get(ATTR_DEVICE_CLASS) in SENSOR_DEVICE_CLASSES
+ if self._attr_native_unit_of_measurement != new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT
):
- self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
-
- if (
- self._attr_state_class is None
- and new_state.attributes.get(ATTR_STATE_CLASS) in SENSOR_STATE_CLASSES
- ):
- self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS)
-
- if self._attr_native_unit_of_measurement is None:
+ for filt in self._filters:
+ filt.reset()
self._attr_native_unit_of_measurement = new_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
@@ -360,11 +352,16 @@ class SensorFilter(SensorEntity):
if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, None]:
self._update_filter_sensor_state(state, False)
- self.async_on_remove(
- async_track_state_change_event(
- self.hass, [self._entity], self._update_filter_sensor_state_event
+ @callback
+ def _async_hass_started(hass: HomeAssistant) -> None:
+ """Delay source entity tracking."""
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass, [self._entity], self._update_filter_sensor_state_event
+ )
)
- )
+
+ self.async_on_remove(async_at_started(self.hass, _async_hass_started))
@property
def native_value(self) -> datetime | StateType:
@@ -460,6 +457,10 @@ class Filter:
"""Return whether the current filter_state should be skipped."""
return self._skip_processing
+ def reset(self) -> None:
+ """Reset filter."""
+ self.states.clear()
+
def _filter_state(self, new_state: FilterState) -> FilterState:
"""Implement filter."""
raise NotImplementedError()
diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py
index 1484ff7f1543..20f8589d2a2f 100644
--- a/homeassistant/components/fireservicerota/sensor.py
+++ b/homeassistant/components/fireservicerota/sensor.py
@@ -23,6 +23,7 @@ async def async_setup_entry(
async_add_entities([IncidentsSensor(client)])
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class IncidentsSensor(RestoreEntity, SensorEntity):
"""Representation of FireServiceRota incidents sensor."""
diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py
index d8a58192593a..f71ccc87f056 100644
--- a/homeassistant/components/flux/switch.py
+++ b/homeassistant/components/flux/switch.py
@@ -226,6 +226,12 @@ class FluxSwitch(SwitchEntity, RestoreEntity):
if last_state and last_state.state == STATE_ON:
await self.async_turn_on()
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ if self.unsub_tracker:
+ self.unsub_tracker()
+ return await super().async_will_remove_from_hass()
+
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on flux."""
if self.is_on:
diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py
index 664d18f385b2..3cff6d017f02 100644
--- a/homeassistant/components/flux_led/sensor.py
+++ b/homeassistant/components/flux_led/sensor.py
@@ -36,7 +36,7 @@ class FluxPairedRemotes(FluxEntity, SensorEntity):
"""Representation of a Magic Home paired remotes sensor."""
_attr_icon = "mdi:remote"
- _attr_entity_category = EntityCategory.CONFIG
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
@property
def native_value(self) -> int:
diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py
index a5c507c3857b..e6ac11889bc4 100644
--- a/homeassistant/components/freedns/__init__.py
+++ b/homeassistant/components/freedns/__init__.py
@@ -1,6 +1,6 @@
"""Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org."""
import asyncio
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
import aiohttp
@@ -53,11 +53,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if result is False:
return False
- async def update_domain_callback(now):
+ async def update_domain_callback(now: datetime) -> None:
"""Update the FreeDNS entry."""
await _update_freedns(hass, session, url, auth_token)
- async_track_time_interval(hass, update_domain_callback, update_interval)
+ async_track_time_interval(
+ hass, update_domain_callback, update_interval, cancel_on_shutdown=True
+ )
return True
diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py
index 60b422eff2f8..26b336208fef 100644
--- a/homeassistant/components/fritz/common.py
+++ b/homeassistant/components/fritz/common.py
@@ -1049,10 +1049,9 @@ class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin):
"""Fritz entity base description."""
-class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity):
+class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrapper]):
"""Fritz host coordinator entity base class."""
- coordinator: AvmWrapper
entity_description: FritzEntityDescription
_attr_has_entity_name = True
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index c8a7952ae2b5..5b8c40485306 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -256,10 +256,9 @@ async def async_setup_entry(
)
-class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity):
+class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity):
"""Fritz switch coordinator base class."""
- coordinator: AvmWrapper
entity_description: SwitchEntityDescription
_attr_has_entity_name = True
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 4e1e0a74fe9b..af8898f28e2d 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20230503.3"]
+ "requirements": ["home-assistant-frontend==20230607.0"]
}
diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py
index 7067f8829736..2274b1cdb449 100644
--- a/homeassistant/components/frontier_silicon/config_flow.py
+++ b/homeassistant/components/frontier_silicon/config_flow.py
@@ -6,12 +6,17 @@ import logging
from typing import Any
from urllib.parse import urlparse
-from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException
+from afsapi import (
+ AFSAPI,
+ ConnectionError as FSConnectionError,
+ InvalidPinException,
+ NotImplementedException,
+)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import ssdp
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from .const import (
@@ -56,41 +61,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
_webfsapi_url: str
_reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows
- async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
- """Handle the import of legacy configuration.yaml entries."""
-
- device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device"
- try:
- webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url)
- except FSConnectionError:
- return self.async_abort(reason="cannot_connect")
- except Exception as exception: # pylint: disable=broad-except
- _LOGGER.exception(exception)
- return self.async_abort(reason="unknown")
-
- try:
- afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN])
-
- unique_id = await afsapi.get_radio_id()
- except FSConnectionError:
- return self.async_abort(reason="cannot_connect")
- except InvalidPinException:
- return self.async_abort(reason="invalid_auth")
- except Exception as exception: # pylint: disable=broad-except
- _LOGGER.exception(exception)
- return self.async_abort(reason="unknown")
-
- await self.async_set_unique_id(unique_id, raise_on_progress=False)
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=import_info[CONF_NAME] or "Radio",
- data={
- CONF_WEBFSAPI_URL: webfsapi_url,
- CONF_PIN: import_info[CONF_PIN],
- },
- )
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -141,14 +111,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
_LOGGER.debug(exception)
return self.async_abort(reason="unknown")
+ # try to login with default pin
+ afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN)
try:
- # try to login with default pin
- afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN)
-
- unique_id = await afsapi.get_radio_id()
+ await afsapi.get_friendly_name()
except InvalidPinException:
return self.async_abort(reason="invalid_auth")
+ try:
+ unique_id = await afsapi.get_radio_id()
+ except NotImplementedException:
+ unique_id = None
+
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True
@@ -175,7 +149,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.context["title_placeholders"] = {"name": self._name}
- unique_id = await afsapi.get_radio_id()
+ try:
+ unique_id = await afsapi.get_radio_id()
+ except NotImplementedException:
+ unique_id = None
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
@@ -240,7 +217,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
- unique_id = await afsapi.get_radio_id()
+ try:
+ unique_id = await afsapi.get_radio_id()
+ except NotImplementedException:
+ unique_id = None
await self.async_set_unique_id(unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
return await self._async_create_entry(user_input[CONF_PIN])
diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py
index 54c17429b56e..04b689ae917c 100644
--- a/homeassistant/components/frontier_silicon/media_player.py
+++ b/homeassistant/components/frontier_silicon/media_player.py
@@ -10,10 +10,8 @@ from afsapi import (
NotImplementedException as FSNotImplementedException,
PlayState,
)
-import voluptuous as vol
from homeassistant.components.media_player import (
- PLATFORM_SCHEMA,
BrowseError,
BrowseMedia,
MediaPlayerEntity,
@@ -21,62 +19,16 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .browse_media import browse_node, browse_top_level
-from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET
+from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET
_LOGGER = logging.getLogger(__name__)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Frontier Silicon platform.
-
- YAML is deprecated, and imported automatically.
- """
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- "remove_yaml",
- breaks_in_ha_version="2023.6.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="removed_yaml",
- )
-
- await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- CONF_NAME: config.get(CONF_NAME),
- CONF_HOST: config.get(CONF_HOST),
- CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT),
- CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN),
- },
- )
-
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json
index 193ca7123f45..a10c3f535a10 100644
--- a/homeassistant/components/frontier_silicon/strings.json
+++ b/homeassistant/components/frontier_silicon/strings.json
@@ -30,11 +30,5 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
- },
- "issues": {
- "removed_yaml": {
- "title": "The Frontier Silicon YAML configuration has been removed",
- "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py
index e417d7c0bcb0..dd1cc70c9f48 100644
--- a/homeassistant/components/fully_kiosk/__init__.py
+++ b/homeassistant/components/fully_kiosk/__init__.py
@@ -26,6 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ coordinator.async_update_listeners()
await async_setup_services(hass)
diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py
index 60009eb6ae4d..eed14f246743 100644
--- a/homeassistant/components/fully_kiosk/sensor.py
+++ b/homeassistant/components/fully_kiosk/sensor.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -12,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -26,11 +27,25 @@ def round_storage(value: int) -> float:
return round(value * 0.000001, 1)
+def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]:
+ """Truncate URL if longer than 256."""
+ url = str(value)
+ truncated = len(url) > 256
+ extra_state_attributes = {
+ "full_url": url,
+ "truncated": truncated,
+ }
+ if truncated:
+ return (url[0:255], extra_state_attributes)
+ return (url, extra_state_attributes)
+
+
@dataclass
class FullySensorEntityDescription(SensorEntityDescription):
"""Fully Kiosk Browser sensor description."""
- state_fn: Callable[[int], float] | None = None
+ round_state_value: bool = False
+ state_fn: Callable[[StateType], tuple[StateType, dict[str, Any]]] | None = None
SENSORS: tuple[FullySensorEntityDescription, ...] = (
@@ -42,6 +57,12 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ FullySensorEntityDescription(
+ key="currentPage",
+ name="Current page",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_fn=truncate_url,
+ ),
FullySensorEntityDescription(
key="screenOrientation",
name="Screen orientation",
@@ -52,11 +73,6 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
name="Foreground app",
entity_category=EntityCategory.DIAGNOSTIC,
),
- FullySensorEntityDescription(
- key="currentPage",
- name="Current page",
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
FullySensorEntityDescription(
key="internalStorageFreeSpace",
name="Internal storage free space",
@@ -64,7 +80,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
- state_fn=round_storage,
+ round_state_value=True,
),
FullySensorEntityDescription(
key="internalStorageTotalSpace",
@@ -73,7 +89,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
- state_fn=round_storage,
+ round_state_value=True,
),
FullySensorEntityDescription(
key="ramFreeMemory",
@@ -82,7 +98,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
- state_fn=round_storage,
+ round_state_value=True,
),
FullySensorEntityDescription(
key="ramTotalMemory",
@@ -91,7 +107,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.MEASUREMENT,
- state_fn=round_storage,
+ round_state_value=True,
),
)
@@ -129,13 +145,19 @@ class FullySensor(FullyKioskEntity, SensorEntity):
super().__init__(coordinator)
- @property
- def native_value(self) -> StateType:
- """Return the state of the sensor."""
- if (value := self.coordinator.data.get(self.entity_description.key)) is None:
- return None
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ extra_state_attributes: dict[str, Any] = {}
+ value = self.coordinator.data.get(self.entity_description.key)
- if self.entity_description.state_fn is not None:
- return self.entity_description.state_fn(value)
+ if value is not None:
+ if self.entity_description.state_fn is not None:
+ value, extra_state_attributes = self.entity_description.state_fn(value)
- return value # type: ignore[no-any-return]
+ if self.entity_description.round_state_value:
+ value = round_storage(value)
+
+ self._attr_native_value = value
+ self._attr_extra_state_attributes = extra_state_attributes
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py
index 6563e26368ab..e1535037d353 100644
--- a/homeassistant/components/gdacs/sensor.py
+++ b/homeassistant/components/gdacs/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DEFAULT_ICON, DOMAIN, FEED
@@ -93,10 +93,12 @@ class GdacsSensor(SensorEntity):
"""Update the internal state from the provided information."""
self._status = status_info.status
self._last_update = (
- dt.as_utc(status_info.last_update) if status_info.last_update else None
+ dt_util.as_utc(status_info.last_update) if status_info.last_update else None
)
if status_info.last_update_successful:
- self._last_update_successful = dt.as_utc(status_info.last_update_successful)
+ self._last_update_successful = dt_util.as_utc(
+ status_info.last_update_successful
+ )
else:
self._last_update_successful = None
self._last_timestamp = status_info.last_timestamp
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
index 693959561d2f..fc06155121b0 100644
--- a/homeassistant/components/generic/manifest.json
+++ b/homeassistant/components/generic/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/generic",
"iot_class": "local_push",
- "requirements": ["ha-av==10.0.0", "pillow==9.5.0"]
+ "requirements": ["ha-av==10.1.0", "pillow==9.5.0"]
}
diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py
index c1ebc948b94d..a6e76330f29b 100644
--- a/homeassistant/components/generic_hygrostat/humidifier.py
+++ b/homeassistant/components/generic_hygrostat/humidifier.py
@@ -172,7 +172,11 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
)
if self._keep_alive:
- async_track_time_interval(self.hass, self._async_operate, self._keep_alive)
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass, self._async_operate, self._keep_alive
+ )
+ )
async def _async_startup(event):
"""Init on startup."""
@@ -216,6 +220,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
await _async_startup(None) # init the sensor
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ if self._remove_stale_tracking:
+ self._remove_stale_tracking()
+ return await super().async_will_remove_from_hass()
+
@property
def available(self):
"""Return True if entity is available."""
diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py
index 0bc612b6e8b8..64b589f4f907 100644
--- a/homeassistant/components/geo_json_events/__init__.py
+++ b/homeassistant/components/geo_json_events/__init__.py
@@ -1 +1,57 @@
-"""The geo_json_events component."""
+"""The GeoJSON events component."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_registry import (
+ async_entries_for_config_entry,
+ async_get,
+)
+
+from .const import DOMAIN, PLATFORMS
+from .manager import GeoJsonFeedEntityManager
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Set up the GeoJSON events component as config entry."""
+ feeds = hass.data.setdefault(DOMAIN, {})
+ # Create feed entity manager for all platforms.
+ manager = GeoJsonFeedEntityManager(hass, config_entry)
+ feeds[config_entry.entry_id] = manager
+ _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id)
+ await remove_orphaned_entities(hass, config_entry.entry_id)
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+ await manager.async_init()
+ return True
+
+
+async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None:
+ """Remove orphaned geo_location entities.
+
+ This is needed because when fetching data from the external feed this integration is
+ determining which entities need to be added, updated or removed by comparing the
+ current with the previous data. After a restart of Home Assistant the integration
+ has no previous data to compare against, and thus all entities managed by this
+ integration are removed after startup.
+ """
+ entity_registry = async_get(hass)
+ orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id)
+ if orphaned_entries is not None:
+ for entry in orphaned_entries:
+ if entry.domain == Platform.GEO_LOCATION:
+ _LOGGER.debug("Removing orphaned entry %s", entry.entity_id)
+ entity_registry.async_remove(entry.entity_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload the GeoJSON events config entry."""
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ if unload_ok:
+ manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id)
+ await manager.async_stop()
+ return unload_ok
diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py
new file mode 100644
index 000000000000..cf58e8b57ce0
--- /dev/null
+++ b/homeassistant/components/geo_json_events/config_flow.py
@@ -0,0 +1,105 @@
+"""Config flow to configure the GeoJSON events integration."""
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_LATITUDE,
+ CONF_LOCATION,
+ CONF_LONGITUDE,
+ CONF_RADIUS,
+ CONF_URL,
+ UnitOfLength,
+)
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers import config_validation as cv, selector
+from homeassistant.util.unit_conversion import DistanceConverter
+
+from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): cv.string,
+ vol.Required(CONF_LOCATION): selector.LocationSelector(
+ selector.LocationSelectorConfig(radius=True, icon="")
+ ),
+ }
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a GeoJSON events config flow."""
+
+ async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
+ """Import a config entry from configuration.yaml."""
+ url: str = import_config[CONF_URL]
+ latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude)
+ longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude)
+ self._async_abort_entries_match(
+ {
+ CONF_URL: url,
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ }
+ )
+ return self.async_create_entry(
+ title=f"{url} ({latitude}, {longitude})",
+ data={
+ CONF_URL: url,
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM),
+ },
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle the start of the config flow."""
+ if not user_input:
+ suggested_values: Mapping[str, Any] = {
+ CONF_LOCATION: {
+ CONF_LATITUDE: self.hass.config.latitude,
+ CONF_LONGITUDE: self.hass.config.longitude,
+ CONF_RADIUS: DEFAULT_RADIUS_IN_M,
+ }
+ }
+ data_schema = self.add_suggested_values_to_schema(
+ DATA_SCHEMA, suggested_values
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=data_schema,
+ )
+
+ url: str = user_input[CONF_URL]
+ location: dict[str, Any] = user_input[CONF_LOCATION]
+ latitude: float = location[CONF_LATITUDE]
+ longitude: float = location[CONF_LONGITUDE]
+ self._async_abort_entries_match(
+ {
+ CONF_URL: url,
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ }
+ )
+ return self.async_create_entry(
+ title=f"{url} ({latitude}, {longitude})",
+ data={
+ CONF_URL: url,
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ CONF_RADIUS: DistanceConverter.convert(
+ location[CONF_RADIUS],
+ UnitOfLength.METERS,
+ UnitOfLength.KILOMETERS,
+ ),
+ },
+ )
diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py
index 4c73be3995ec..15f8b0a5b84f 100644
--- a/homeassistant/components/geo_json_events/const.py
+++ b/homeassistant/components/geo_json_events/const.py
@@ -4,11 +4,16 @@ from __future__ import annotations
from datetime import timedelta
from typing import Final
+from homeassistant.const import Platform
+
DOMAIN: Final = "geo_json_events"
+PLATFORMS: Final = [Platform.GEO_LOCATION]
+
ATTR_EXTERNAL_ID: Final = "external_id"
DEFAULT_RADIUS_IN_KM: Final = 20.0
-DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5)
+DEFAULT_RADIUS_IN_M: Final = 20000.0
+DEFAULT_UPDATE_INTERVAL: Final = timedelta(seconds=300)
SOURCE: Final = "geo_json_events"
SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}"
diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py
index df2978b654e4..def8f77994ef 100644
--- a/homeassistant/components/geo_json_events/geo_location.py
+++ b/homeassistant/components/geo_json_events/geo_location.py
@@ -2,42 +2,41 @@
from __future__ import annotations
from collections.abc import Callable
-from datetime import timedelta
import logging
from typing import Any
-from aio_geojson_generic_client import GenericFeedManager
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
import voluptuous as vol
from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_RADIUS,
- CONF_SCAN_INTERVAL,
CONF_URL,
- EVENT_HOMEASSISTANT_START,
UnitOfLength,
)
-from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from . import GeoJsonFeedEntityManager
from .const import (
ATTR_EXTERNAL_ID,
DEFAULT_RADIUS_IN_KM,
- DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
SIGNAL_DELETE_ENTITY,
SIGNAL_UPDATE_ENTITY,
SOURCE,
)
-from .manager import GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
+# Deprecated.
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_URL): cv.string,
@@ -48,28 +47,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the GeoJSON Events platform."""
- url: str = config[CONF_URL]
- scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- coordinates: tuple[float, float] = (
- config.get(CONF_LATITUDE, hass.config.latitude),
- config.get(CONF_LONGITUDE, hass.config.longitude),
- )
- radius_in_km: float = config[CONF_RADIUS]
- # Initialize the entity manager.
- manager = GeoJsonFeedEntityManager(
- hass, scan_interval, coordinates, url, radius_in_km
- )
+ manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id]
@callback
def async_add_geolocation(
- feed_manager: GenericFeedManager,
+ feed_manager: GeoJsonFeedEntityManager,
external_id: str,
) -> None:
"""Add geolocation entity from feed."""
@@ -77,15 +63,36 @@ async def async_setup_platform(
_LOGGER.debug("Adding geolocation %s", new_entity)
async_add_entities([new_entity], True)
- async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation)
+ manager.listeners.append(
+ async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation)
+ )
+ # Do not wait for update here so that the setup can be completed and because an
+ # update will fetch data from the feed via HTTP and then process that data.
+ entry.async_create_task(hass, manager.async_update())
+ _LOGGER.debug("Geolocation setup done")
- await manager.async_init()
- async def start_feed_manager(event: Event) -> None:
- """Start feed manager."""
- await manager.async_update()
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
+async def async_setup_platform(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_add_entities: AddEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the GeoJSON Events platform."""
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ )
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ )
class GeoJsonLocationEvent(GeolocationEvent):
@@ -95,10 +102,15 @@ class GeoJsonLocationEvent(GeolocationEvent):
_attr_source = SOURCE
_attr_unit_of_measurement = UnitOfLength.KILOMETERS
- def __init__(self, feed_manager: GenericFeedManager, external_id: str) -> None:
+ def __init__(
+ self,
+ feed_manager: GeoJsonFeedEntityManager,
+ external_id: str,
+ ) -> None:
"""Initialize entity with data from feed entry."""
self._feed_manager = feed_manager
self._external_id = external_id
+ self._attr_unique_id = f"{feed_manager.entry_id}_{external_id}"
self._remove_signal_delete: Callable[[], None]
self._remove_signal_update: Callable[[], None]
diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py
index a999d224ac78..93f74831ecbc 100644
--- a/homeassistant/components/geo_json_events/manager.py
+++ b/homeassistant/components/geo_json_events/manager.py
@@ -1,18 +1,26 @@
"""Entity manager for generic GeoJSON events."""
from __future__ import annotations
-from datetime import datetime, timedelta
+from collections.abc import Callable
+from datetime import datetime
import logging
from aio_geojson_generic_client import GenericFeedManager
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
-from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY
+from .const import (
+ DEFAULT_UPDATE_INTERVAL,
+ DOMAIN,
+ SIGNAL_DELETE_ENTITY,
+ SIGNAL_UPDATE_ENTITY,
+)
_LOGGER = logging.getLogger(__name__)
@@ -23,27 +31,28 @@ class GeoJsonFeedEntityManager:
def __init__(
self,
hass: HomeAssistant,
- scan_interval: timedelta,
- coordinates: tuple[float, float],
- url: str,
- radius_in_km: float,
+ config_entry: ConfigEntry,
) -> None:
"""Initialize the GeoJSON Feed Manager."""
-
- self._hass = hass
+ self._hass: HomeAssistant = hass
+ self.entry_id: str = config_entry.entry_id
websession = aiohttp_client.async_get_clientsession(hass)
- self._feed_manager = GenericFeedManager(
+ self._feed_manager: GenericFeedManager = GenericFeedManager(
websession,
self._generate_entity,
self._update_entity,
self._remove_entity,
- coordinates,
- url,
- filter_radius=radius_in_km,
+ (
+ config_entry.data[CONF_LATITUDE],
+ config_entry.data[CONF_LONGITUDE],
+ ),
+ config_entry.data[CONF_URL],
+ filter_radius=config_entry.data[CONF_RADIUS],
)
- self._scan_interval = scan_interval
- self.signal_new_entity = (
- f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}"
+ self._track_time_remove_callback: Callable[[], None] | None = None
+ self.listeners: list[Callable[[], None]] = []
+ self.signal_new_entity: str = (
+ f"{DOMAIN}_new_geolocation_{config_entry.entry_id}"
)
async def async_init(self) -> None:
@@ -54,7 +63,10 @@ class GeoJsonFeedEntityManager:
await self.async_update()
# Trigger updates at regular intervals.
- async_track_time_interval(self._hass, update, self._scan_interval)
+ self._track_time_remove_callback = async_track_time_interval(
+ self._hass, update, DEFAULT_UPDATE_INTERVAL
+ )
+
_LOGGER.debug("Feed entity manager initialized")
async def async_update(self) -> None:
@@ -62,6 +74,15 @@ class GeoJsonFeedEntityManager:
await self._feed_manager.update()
_LOGGER.debug("Feed entity manager updated")
+ async def async_stop(self) -> None:
+ """Stop this feed entity manager from refreshing."""
+ for unsub_dispatcher in self.listeners:
+ unsub_dispatcher()
+ self.listeners = []
+ if self._track_time_remove_callback:
+ self._track_time_remove_callback()
+ _LOGGER.debug("Feed entity manager stopped")
+
def get_entry(self, external_id: str) -> GenericFeedEntry | None:
"""Get feed entry by external id."""
return self._feed_manager.feed_entries.get(external_id)
diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json
index 8e381449411c..b02339eb20ac 100644
--- a/homeassistant/components/geo_json_events/manifest.json
+++ b/homeassistant/components/geo_json_events/manifest.json
@@ -2,6 +2,7 @@
"domain": "geo_json_events",
"name": "GeoJSON",
"codeowners": ["@exxamalte"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/geo_json_events",
"integration_type": "service",
"iot_class": "cloud_polling",
diff --git a/homeassistant/components/geo_json_events/strings.json b/homeassistant/components/geo_json_events/strings.json
new file mode 100644
index 000000000000..e50369d6e749
--- /dev/null
+++ b/homeassistant/components/geo_json_events/strings.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Fill in your GeoJSON feed details.",
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "location": "[%key:common::config_flow::data::location%]"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml": {
+ "title": "The GeoJSON feed YAML configuration is being removed",
+ "description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py
index 8fb2ff8535b4..e69ba6eb005c 100644
--- a/homeassistant/components/geonetnz_quakes/sensor.py
+++ b/homeassistant/components/geonetnz_quakes/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, FEED
@@ -94,10 +94,12 @@ class GeonetnzQuakesSensor(SensorEntity):
"""Update the internal state from the provided information."""
self._status = status_info.status
self._last_update = (
- dt.as_utc(status_info.last_update) if status_info.last_update else None
+ dt_util.as_utc(status_info.last_update) if status_info.last_update else None
)
if status_info.last_update_successful:
- self._last_update_successful = dt.as_utc(status_info.last_update_successful)
+ self._last_update_successful = dt_util.as_utc(
+ status_info.last_update_successful
+ )
else:
self._last_update_successful = None
self._last_timestamp = status_info.last_timestamp
diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py
index 33a879eeb255..583b75a24eb3 100644
--- a/homeassistant/components/geonetnz_volcano/sensor.py
+++ b/homeassistant/components/geonetnz_volcano/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import DistanceConverter
from .const import (
@@ -124,9 +124,9 @@ class GeonetnzVolcanoSensor(SensorEntity):
self._alert_level = feed_entry.alert_level
self._activity = feed_entry.activity
self._hazards = feed_entry.hazards
- self._feed_last_update = dt.as_utc(last_update) if last_update else None
+ self._feed_last_update = dt_util.as_utc(last_update) if last_update else None
self._feed_last_update_successful = (
- dt.as_utc(last_update_successful) if last_update_successful else None
+ dt_util.as_utc(last_update_successful) if last_update_successful else None
)
@property
diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py
index b704ab326f41..37da60bdea80 100644
--- a/homeassistant/components/glances/const.py
+++ b/homeassistant/components/glances/const.py
@@ -1,5 +1,6 @@
"""Constants for Glances component."""
+from datetime import timedelta
import sys
DOMAIN = "glances"
@@ -8,7 +9,7 @@ CONF_VERSION = "version"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 61208
DEFAULT_VERSION = 3
-DEFAULT_SCAN_INTERVAL = 60
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
SUPPORTED_VERSIONS = [2, 3]
diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py
index 01e498a88979..24a2e23a013f 100644
--- a/homeassistant/components/glances/coordinator.py
+++ b/homeassistant/components/glances/coordinator.py
@@ -1,5 +1,4 @@
"""Coordinator for Glances integration."""
-from datetime import timedelta
import logging
from typing import Any
@@ -10,7 +9,7 @@ from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -30,7 +29,7 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
hass,
_LOGGER,
name=f"{DOMAIN} - {self.host}",
- update_interval=timedelta(seconds=60),
+ update_interval=DEFAULT_SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, Any]:
diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py
index 8b836fba3eaa..f4a3f8827491 100644
--- a/homeassistant/components/glances/sensor.py
+++ b/homeassistant/components/glances/sensor.py
@@ -328,6 +328,18 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}"
+ @property
+ def available(self) -> bool:
+ """Set sensor unavailable when native value is invalid."""
+ if super().available:
+ return (
+ not self._numeric_state_expected
+ or isinstance(value := self.native_value, (int, float))
+ or isinstance(value, str)
+ and value.isnumeric()
+ )
+ return False
+
@property
def native_value(self) -> StateType:
"""Return the state of the resources."""
diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py
deleted file mode 100644
index f452b858e799..000000000000
--- a/homeassistant/components/goalfeed/__init__.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""Component for the Goalfeed service."""
-import json
-
-import pysher
-import requests
-import voluptuous as vol
-
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.typing import ConfigType
-
-# Version downgraded due to regression in library
-# For details: https://github.com/nlsdfnbch/Pysher/issues/38
-DOMAIN = "goalfeed"
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-GOALFEED_HOST = "feed.goalfeed.ca"
-GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth"
-GOALFEED_APP_ID = "bfd4ed98c1ff22c04074"
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Goalfeed component."""
- conf = config[DOMAIN]
- username = conf.get(CONF_USERNAME)
- password = conf.get(CONF_PASSWORD)
-
- def goal_handler(data):
- """Handle goal events."""
- goal = json.loads(json.loads(data))
-
- hass.bus.fire("goal", event_data=goal)
-
- def connect_handler(data):
- """Handle connection."""
- post_data = {
- "username": username,
- "password": password,
- "connection_info": data,
- }
- resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json()
-
- channel = pusher.subscribe("private-goals", resp["auth"])
- channel.bind("goal", goal_handler)
-
- pusher = pysher.Pusher(
- GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST
- )
-
- pusher.connection.bind("pusher:connection_established", connect_handler)
- pusher.connect()
-
- return True
diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json
deleted file mode 100644
index 077596b01853..000000000000
--- a/homeassistant/components/goalfeed/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "domain": "goalfeed",
- "name": "Goalfeed",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/goalfeed",
- "iot_class": "cloud_push",
- "loggers": ["pysher"],
- "requirements": ["pysher==1.0.7"]
-}
diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py
index 47aa32dcd11c..a3a5b7246b61 100644
--- a/homeassistant/components/google/api.py
+++ b/homeassistant/components/google/api.py
@@ -24,7 +24,7 @@ from homeassistant.helpers.event import (
async_track_point_in_utc_time,
async_track_time_interval,
)
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
CONF_CALENDAR_ACCESS,
@@ -51,7 +51,9 @@ class DeviceAuth(AuthImplementation):
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve a Google API Credentials object to Home Assistant token."""
creds: Credentials = external_data[DEVICE_AUTH_CREDS]
- delta = creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt.utcnow()
+ delta = (
+ creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt_util.utcnow()
+ )
_LOGGER.debug(
"Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds()
)
@@ -108,7 +110,9 @@ class DeviceFlow:
def async_start_exchange(self) -> None:
"""Start the device auth exchange flow polling."""
_LOGGER.debug("Starting exchange flow")
- max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS)
+ max_timeout = dt_util.utcnow() + datetime.timedelta(
+ seconds=EXCHANGE_TIMEOUT_SECONDS
+ )
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
# object without tzinfo. For the comparison below to work, it needs one.
user_code_expiry = self._device_flow_info.user_code_expiry.replace(
diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py
index bf511f8eaebb..918cec046fb7 100644
--- a/homeassistant/components/google_assistant/const.py
+++ b/homeassistant/components/google_assistant/const.py
@@ -186,7 +186,7 @@ STORE_GOOGLE_LOCAL_WEBHOOK_ID = "local_webhook_id"
SOURCE_CLOUD = "cloud"
SOURCE_LOCAL = "local"
-NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK}
+NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK, TYPE_THERMOSTAT}
FAN_SPEEDS = {
"5/5": ["High", "Max", "Fast", "5"],
diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py
index e194242df91a..49d130d66569 100644
--- a/homeassistant/components/google_assistant/helpers.py
+++ b/homeassistant/components/google_assistant/helpers.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_NAME,
STATE_UNAVAILABLE,
)
-from homeassistant.core import Context, HomeAssistant, State, callback
+from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
@@ -86,19 +86,19 @@ def _get_registry_entries(
class AbstractConfig(ABC):
"""Hold the configuration for Google Assistant."""
+ _store: GoogleConfigStore
_unsub_report_state: Callable[[], None] | None = None
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize abstract config."""
self.hass = hass
- self._store = None
- self._google_sync_unsub = {}
+ self._google_sync_unsub: dict[str, CALLBACK_TYPE] = {}
self._local_sdk_active = False
self._local_last_active: datetime | None = None
self._local_sdk_version_warn = False
self.is_supported_cache: dict[str, tuple[int | None, bool]] = {}
- async def async_initialize(self):
+ async def async_initialize(self) -> None:
"""Perform async initialization of config."""
self._store = GoogleConfigStore(self.hass)
await self._store.async_initialize()
@@ -195,7 +195,7 @@ class AbstractConfig(ABC):
await gather(*jobs)
@callback
- def async_enable_report_state(self):
+ def async_enable_report_state(self) -> None:
"""Enable proactive mode."""
# Circular dep
# pylint: disable-next=import-outside-toplevel
@@ -205,7 +205,7 @@ class AbstractConfig(ABC):
self._unsub_report_state = async_enable_report_state(self.hass, self)
@callback
- def async_disable_report_state(self):
+ def async_disable_report_state(self) -> None:
"""Disable report state."""
if self._unsub_report_state is not None:
self._unsub_report_state()
@@ -220,7 +220,7 @@ class AbstractConfig(ABC):
await self.async_disconnect_agent_user(agent_user_id)
return status
- async def async_sync_entities_all(self):
+ async def async_sync_entities_all(self) -> int:
"""Sync all entities to Google for all registered agents."""
if not self._store.agent_user_ids:
return 204
@@ -249,7 +249,7 @@ class AbstractConfig(ABC):
)
@callback
- def async_schedule_google_sync_all(self):
+ def async_schedule_google_sync_all(self) -> None:
"""Schedule a sync for all registered agents."""
for agent_user_id in self._store.agent_user_ids:
self.async_schedule_google_sync(agent_user_id)
@@ -279,7 +279,7 @@ class AbstractConfig(ABC):
self._store.pop_agent_user_id(agent_user_id)
@callback
- def async_enable_local_sdk(self):
+ def async_enable_local_sdk(self) -> None:
"""Enable the local SDK."""
setup_successful = True
setup_webhook_ids = []
@@ -323,7 +323,7 @@ class AbstractConfig(ABC):
self._local_sdk_active = setup_successful
@callback
- def async_disable_local_sdk(self):
+ def async_disable_local_sdk(self) -> None:
"""Disable the local SDK."""
if not self._local_sdk_active:
return
@@ -500,7 +500,7 @@ class GoogleEntity:
self.hass = hass
self.config = config
self.state = state
- self._traits = None
+ self._traits: list[trait._Trait] | None = None
@property
def entity_id(self):
@@ -508,7 +508,7 @@ class GoogleEntity:
return self.state.entity_id
@callback
- def traits(self):
+ def traits(self) -> list[trait._Trait]:
"""Return traits for entity."""
if self._traits is not None:
return self._traits
diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py
index 737b54c8b1e9..109ea61dbab6 100644
--- a/homeassistant/components/google_assistant/report_state.py
+++ b/homeassistant/components/google_assistant/report_state.py
@@ -139,7 +139,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
unsub = async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
- unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report)
+ unsub = async_call_later(
+ hass, INITIAL_REPORT_DELAY, HassJob(initial_report, cancel_on_shutdown=True)
+ )
@callback
def unsub_all():
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
index 3752574f31f1..6f7dbd2c3b02 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -1,6 +1,7 @@
"""Implement the Google Smart Home traits."""
from __future__ import annotations
+from abc import ABC, abstractmethod
import logging
from typing import Any, TypeVar
@@ -67,7 +68,7 @@ from homeassistant.const import (
)
from homeassistant.core import DOMAIN as HA_DOMAIN
from homeassistant.helpers.network import get_url
-from homeassistant.util import color as color_util, dt
+from homeassistant.util import color as color_util, dt as dt_util
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -196,9 +197,10 @@ def _next_selected(items: list[str], selected: str | None) -> str | None:
return items[next_item]
-class _Trait:
+class _Trait(ABC):
"""Represents a Trait inside Google Assistant skill."""
+ name: str
commands: list[str] = []
@staticmethod
@@ -206,6 +208,11 @@ class _Trait:
"""Return if the trait might ask for 2FA."""
return False
+ @staticmethod
+ @abstractmethod
+ def supported(domain, features, device_class, attributes):
+ """Test if state is supported."""
+
def __init__(self, hass, state, config):
"""Initialize a trait for a state."""
self.hass = hass
@@ -2211,7 +2218,7 @@ class TransportControlTrait(_Trait):
rel_position = params["relativePositionMs"] / 1000
seconds_since = 0 # Default to 0 seconds
if self.state.state == STATE_PLAYING:
- now = dt.utcnow()
+ now = dt_util.utcnow()
upd_at = self.state.attributes.get(
media_player.ATTR_MEDIA_POSITION_UPDATED_AT, now
)
diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py
index 0de580ef7b7a..c8f6869f6e4e 100644
--- a/homeassistant/components/google_cloud/tts.py
+++ b/homeassistant/components/google_cloud/tts.py
@@ -40,11 +40,14 @@ SUPPORTED_LANGUAGES = [
"en-US",
"es-ES",
"es-US",
+ "eu-ES",
"fi-FI",
"fil-PH",
"fr-CA",
"fr-FR",
+ "gl-ES",
"gu-IN",
+ "he-IL",
"hi-IN",
"hu-HU",
"id-ID",
@@ -54,7 +57,9 @@ SUPPORTED_LANGUAGES = [
"kn-IN",
"ko-KR",
"lv-LV",
+ "lt-LT",
"ml-IN",
+ "mr-IN",
"ms-MY",
"nb-NO",
"nl-BE",
@@ -236,7 +241,7 @@ class GoogleCloudTTSProvider(Provider):
CONF_TEXT_TYPE: self._text_type,
}
- async def async_get_tts_audio(self, message, language, options=None):
+ async def async_get_tts_audio(self, message, language, options):
"""Load TTS from google."""
options_schema = vol.Schema(
{
diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py
new file mode 100644
index 000000000000..3d0fac634207
--- /dev/null
+++ b/homeassistant/components/google_generative_ai_conversation/__init__.py
@@ -0,0 +1,157 @@
+"""The Google Generative AI Conversation integration."""
+from __future__ import annotations
+
+from functools import partial
+import logging
+from typing import Literal
+
+from google.api_core.exceptions import ClientError
+import google.generativeai as palm
+from google.generativeai.types.discuss_types import ChatResponse
+
+from homeassistant.components import conversation
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, MATCH_ALL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady, TemplateError
+from homeassistant.helpers import intent, template
+from homeassistant.util import ulid
+
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_PROMPT,
+ CONF_TEMPERATURE,
+ CONF_TOP_K,
+ CONF_TOP_P,
+ DEFAULT_CHAT_MODEL,
+ DEFAULT_PROMPT,
+ DEFAULT_TEMPERATURE,
+ DEFAULT_TOP_K,
+ DEFAULT_TOP_P,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Google Generative AI Conversation from a config entry."""
+ palm.configure(api_key=entry.data[CONF_API_KEY])
+
+ try:
+ await hass.async_add_executor_job(
+ partial(
+ palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL)
+ )
+ )
+ except ClientError as err:
+ if err.reason == "API_KEY_INVALID":
+ _LOGGER.error("Invalid API key: %s", err)
+ return False
+ raise ConfigEntryNotReady(err) from err
+
+ conversation.async_set_agent(hass, entry, GoogleGenerativeAIAgent(hass, entry))
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload GoogleGenerativeAI."""
+ palm.configure(api_key=None)
+ conversation.async_unset_agent(hass, entry)
+ return True
+
+
+class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
+ """Google Generative AI conversation agent."""
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Initialize the agent."""
+ self.hass = hass
+ self.entry = entry
+ self.history: dict[str, list[dict]] = {}
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return {
+ "name": "Powered by Google Generative AI",
+ "url": "https://developers.generativeai.google/",
+ }
+
+ @property
+ def supported_languages(self) -> list[str] | Literal["*"]:
+ """Return a list of supported languages."""
+ return MATCH_ALL
+
+ async def async_process(
+ self, user_input: conversation.ConversationInput
+ ) -> conversation.ConversationResult:
+ """Process a sentence."""
+ raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT)
+ model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL)
+ temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
+ top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P)
+ top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K)
+
+ if user_input.conversation_id in self.history:
+ conversation_id = user_input.conversation_id
+ messages = self.history[conversation_id]
+ else:
+ conversation_id = ulid.ulid()
+ messages = []
+
+ try:
+ prompt = self._async_generate_prompt(raw_prompt)
+ except TemplateError as err:
+ _LOGGER.error("Error rendering prompt: %s", err)
+ intent_response = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Sorry, I had a problem with my template: {err}",
+ )
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ messages.append({"author": "0", "content": user_input.text})
+
+ _LOGGER.debug("Prompt for %s: %s", model, messages)
+
+ try:
+ chat_response: ChatResponse = await palm.chat_async(
+ model=model,
+ context=prompt,
+ messages=messages,
+ temperature=temperature,
+ top_p=top_p,
+ top_k=top_k,
+ )
+ except ClientError as err:
+ intent_response = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Sorry, I had a problem talking to Google Generative AI: {err}",
+ )
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ _LOGGER.debug("Response %s", chat_response)
+ # For some queries the response is empty. In that case don't update history to avoid
+ # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating"
+ if chat_response.last:
+ self.history[conversation_id] = chat_response.messages
+
+ intent_response = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_speech(chat_response.last)
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ def _async_generate_prompt(self, raw_prompt: str) -> str:
+ """Generate a prompt for the user."""
+ return template.Template(raw_prompt, self.hass).async_render(
+ {
+ "ha_name": self.hass.config.location_name,
+ },
+ parse_result=False,
+ )
diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py
new file mode 100644
index 000000000000..94639177a42f
--- /dev/null
+++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py
@@ -0,0 +1,165 @@
+"""Config flow for Google Generative AI Conversation integration."""
+from __future__ import annotations
+
+from functools import partial
+import logging
+import types
+from types import MappingProxyType
+from typing import Any
+
+from google.api_core.exceptions import ClientError
+import google.generativeai as palm
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ TemplateSelector,
+)
+
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_PROMPT,
+ CONF_TEMPERATURE,
+ CONF_TOP_K,
+ CONF_TOP_P,
+ DEFAULT_CHAT_MODEL,
+ DEFAULT_PROMPT,
+ DEFAULT_TEMPERATURE,
+ DEFAULT_TOP_K,
+ DEFAULT_TOP_P,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+
+DEFAULT_OPTIONS = types.MappingProxyType(
+ {
+ CONF_PROMPT: DEFAULT_PROMPT,
+ CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL,
+ CONF_TEMPERATURE: DEFAULT_TEMPERATURE,
+ CONF_TOP_P: DEFAULT_TOP_P,
+ CONF_TOP_K: DEFAULT_TOP_K,
+ }
+)
+
+
+async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
+ """Validate the user input allows us to connect.
+
+ Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+ """
+ palm.configure(api_key=data[CONF_API_KEY])
+ await hass.async_add_executor_job(partial(palm.list_models))
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Google Generative AI Conversation."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle the initial step."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA
+ )
+
+ errors = {}
+
+ try:
+ await validate_input(self.hass, user_input)
+ except ClientError as err:
+ if err.reason == "API_KEY_INVALID":
+ errors["base"] = "invalid_auth"
+ else:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title="Google Generative AI Conversation", data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ @staticmethod
+ def async_get_options_flow(
+ config_entry: config_entries.ConfigEntry,
+ ) -> config_entries.OptionsFlow:
+ """Create the options flow."""
+ return OptionsFlow(config_entry)
+
+
+class OptionsFlow(config_entries.OptionsFlow):
+ """Google Generative AI config flow options handler."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
+ """Initialize options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Manage the options."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title="Google Generative AI Conversation", data=user_input
+ )
+ schema = google_generative_ai_config_option_schema(self.config_entry.options)
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(schema),
+ )
+
+
+def google_generative_ai_config_option_schema(
+ options: MappingProxyType[str, Any]
+) -> dict:
+ """Return a schema for Google Generative AI completion options."""
+ if not options:
+ options = DEFAULT_OPTIONS
+ return {
+ vol.Optional(
+ CONF_PROMPT,
+ description={"suggested_value": options[CONF_PROMPT]},
+ default=DEFAULT_PROMPT,
+ ): TemplateSelector(),
+ vol.Optional(
+ CONF_CHAT_MODEL,
+ description={
+ "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL)
+ },
+ default=DEFAULT_CHAT_MODEL,
+ ): str,
+ vol.Optional(
+ CONF_TEMPERATURE,
+ description={"suggested_value": options[CONF_TEMPERATURE]},
+ default=DEFAULT_TEMPERATURE,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ vol.Optional(
+ CONF_TOP_P,
+ description={"suggested_value": options[CONF_TOP_P]},
+ default=DEFAULT_TOP_P,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ vol.Optional(
+ CONF_TOP_K,
+ description={"suggested_value": options[CONF_TOP_K]},
+ default=DEFAULT_TOP_K,
+ ): int,
+ }
diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py
new file mode 100644
index 000000000000..9664552e4362
--- /dev/null
+++ b/homeassistant/components/google_generative_ai_conversation/const.py
@@ -0,0 +1,33 @@
+"""Constants for the Google Generative AI Conversation integration."""
+
+DOMAIN = "google_generative_ai_conversation"
+CONF_PROMPT = "prompt"
+DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
+
+An overview of the areas and the devices in this smart home:
+{%- for area in areas() %}
+ {%- set area_info = namespace(printed=false) %}
+ {%- for device in area_devices(area) -%}
+ {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
+ {%- if not area_info.printed %}
+
+{{ area_name(area) }}:
+ {%- set area_info.printed = true %}
+ {%- endif %}
+- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
+ {%- endif %}
+ {%- endfor %}
+{%- endfor %}
+
+Answer the user's questions about the world truthfully.
+
+If the user wants to control a device, reject the request and suggest using the Home Assistant app.
+"""
+CONF_CHAT_MODEL = "chat_model"
+DEFAULT_CHAT_MODEL = "models/chat-bison-001"
+CONF_TEMPERATURE = "temperature"
+DEFAULT_TEMPERATURE = 0.25
+CONF_TOP_P = "top_p"
+DEFAULT_TOP_P = 0.95
+CONF_TOP_K = "top_k"
+DEFAULT_TOP_K = 40
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
new file mode 100644
index 000000000000..52de92155354
--- /dev/null
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "google_generative_ai_conversation",
+ "name": "Google Generative AI Conversation",
+ "codeowners": ["@tronikos"],
+ "config_flow": true,
+ "dependencies": ["conversation"],
+ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "requirements": ["google-generativeai==0.1.0rc2"]
+}
diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json
new file mode 100644
index 000000000000..2df5398222c8
--- /dev/null
+++ b/homeassistant/components/google_generative_ai_conversation/strings.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "prompt": "Prompt Template",
+ "model": "Model",
+ "temperature": "Temperature",
+ "top_p": "Top P",
+ "top_k": "Top K"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py
index 803b737283b3..590c7bd0c902 100644
--- a/homeassistant/components/google_sheets/__init__.py
+++ b/homeassistant/components/google_sheets/__init__.py
@@ -7,13 +7,18 @@ import aiohttp
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from gspread import Client
+from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ HomeAssistantError,
+)
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
@@ -93,6 +98,9 @@ async def async_setup_service(hass: HomeAssistant) -> None:
except RefreshError as ex:
entry.async_start_reauth(hass)
raise ex
+ except APIError as ex:
+ raise HomeAssistantError("Failed to write data") from ex
+
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
row_data = {"created": str(datetime.now())} | call.data[DATA]
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json
index 5321d13c5d61..504925a4667f 100644
--- a/homeassistant/components/google_translate/manifest.json
+++ b/homeassistant/components/google_translate/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "google_translate",
- "name": "Google Translate Text-to-Speech",
+ "name": "Google Translate text-to-speech",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/google_translate",
"iot_class": "cloud_push",
diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py
index b720498b4f1b..c02d262f6e5c 100644
--- a/homeassistant/components/google_translate/tts.py
+++ b/homeassistant/components/google_translate/tts.py
@@ -59,13 +59,13 @@ class GoogleProvider(Provider):
"""Return a list of supported options."""
return SUPPORT_OPTIONS
- def get_tts_audio(self, message, language, options=None):
+ def get_tts_audio(self, message, language, options):
"""Load TTS from google."""
tld = self._tld
if language in MAP_LANG_TLD:
tld = MAP_LANG_TLD[language].tld
language = MAP_LANG_TLD[language].lang
- if options is not None and "tld" in options:
+ if "tld" in options:
tld = options["tld"]
tts = gTTS(text=message, lang=language, tld=tld)
mp3_data = BytesIO()
diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py
index 9fe264219ece..6bf552b824be 100644
--- a/homeassistant/components/google_wifi/sensor.py
+++ b/homeassistant/components/google_wifi/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle, dt
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -212,7 +212,7 @@ class GoogleWifiAPI:
elif attr_key == ATTR_UPTIME:
sensor_value = round(sensor_value / (3600 * 24), 2)
elif attr_key == ATTR_LAST_RESTART:
- last_restart = dt.now() - timedelta(seconds=sensor_value)
+ last_restart = dt_util.now() - timedelta(seconds=sensor_value)
sensor_value = last_restart.strftime("%Y-%m-%d %H:%M:%S")
elif attr_key == ATTR_STATUS:
if sensor_value:
diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py
index 53a8fd062641..6cdc47f9e856 100644
--- a/homeassistant/components/group/config_flow.py
+++ b/homeassistant/components/group/config_flow.py
@@ -24,14 +24,14 @@ from .binary_sensor import CONF_ALL
from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC
_STATISTIC_MEASURES = [
- selector.SelectOptionDict(value="min", label="Minimum"),
- selector.SelectOptionDict(value="max", label="Maximum"),
- selector.SelectOptionDict(value="mean", label="Arithmetic mean"),
- selector.SelectOptionDict(value="median", label="Median"),
- selector.SelectOptionDict(value="last", label="Most recently updated"),
- selector.SelectOptionDict(value="range", label="Statistical range"),
- selector.SelectOptionDict(value="sum", label="Sum"),
- selector.SelectOptionDict(value="product", label="Product"),
+ "min",
+ "max",
+ "mean",
+ "median",
+ "last",
+ "range",
+ "sum",
+ "product",
]
@@ -80,13 +80,17 @@ BINARY_SENSOR_CONFIG_SCHEMA = basic_group_config_schema("binary_sensor").extend(
SENSOR_CONFIG_EXTENDS = {
vol.Required(CONF_TYPE): selector.SelectSelector(
- selector.SelectSelectorConfig(options=_STATISTIC_MEASURES),
+ selector.SelectSelectorConfig(
+ options=_STATISTIC_MEASURES, translation_key=CONF_TYPE
+ ),
),
}
SENSOR_OPTIONS = {
vol.Optional(CONF_IGNORE_NON_NUMERIC, default=False): selector.BooleanSelector(),
vol.Required(CONF_TYPE): selector.SelectSelector(
- selector.SelectSelectorConfig(options=_STATISTIC_MEASURES),
+ selector.SelectSelectorConfig(
+ options=_STATISTIC_MEASURES, translation_key=CONF_TYPE
+ ),
),
}
diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json
index 9f5054546812..192823cef651 100644
--- a/homeassistant/components/group/strings.json
+++ b/homeassistant/components/group/strings.json
@@ -176,5 +176,19 @@
}
}
}
+ },
+ "selector": {
+ "type": {
+ "options": {
+ "min": "Minimum",
+ "max": "Maximum",
+ "mean": "Arithmetic mean",
+ "median": "Median",
+ "last": "Most recently updated",
+ "range": "Statistical range",
+ "sum": "Sum",
+ "product": "Product"
+ }
+ }
}
}
diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py
index f4150068399f..a25f8f0342f2 100644
--- a/homeassistant/components/growatt_server/sensor.py
+++ b/homeassistant/components/growatt_server/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAM
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import Throttle, dt
+from homeassistant.util import Throttle, dt as dt_util
from .const import (
CONF_PLANT_ID,
@@ -234,10 +234,10 @@ class GrowattData:
sorted_keys = sorted(mix_chart_entries)
# Create datetime from the latest entry
- date_now = dt.now().date()
- last_updated_time = dt.parse_time(str(sorted_keys[-1]))
+ date_now = dt_util.now().date()
+ last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
mix_detail["lastdataupdate"] = datetime.datetime.combine(
- date_now, last_updated_time, dt.DEFAULT_TIME_ZONE
+ date_now, last_updated_time, dt_util.DEFAULT_TIME_ZONE
)
# Dashboard data is largely inaccurate for mix system but it is the only
diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py
index a11985342131..2e00771199cd 100644
--- a/homeassistant/components/hardware/__init__.py
+++ b/homeassistant/components/hardware/__init__.py
@@ -2,11 +2,14 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
from .const import DOMAIN
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Hardware."""
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index 42a51c218b12..2ae4faa7878f 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -61,6 +61,7 @@ from .const import (
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_INPUT,
+ ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_REPOSITORY,
ATTR_SLUG,
@@ -161,6 +162,9 @@ SCHEMA_BACKUP_FULL = vol.Schema(
vol.Optional(ATTR_NAME): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
+ vol.Optional(ATTR_LOCATION): vol.All(
+ cv.string, lambda v: None if v == "/backup" else v
+ ),
}
)
@@ -295,7 +299,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None:
@callback
@bind_hass
-def get_addons_info(hass):
+def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
"""Return Addons info.
Async friendly.
@@ -363,6 +367,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
return hass.data.get(DATA_CORE_INFO)
+@callback
+@bind_hass
+def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
+ """Return Supervisor issues info.
+
+ Async friendly.
+ """
+ return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES)
+
+
@callback
@bind_hass
def is_hassio(hass: HomeAssistant) -> bool:
@@ -590,13 +604,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
await async_setup_addon_panel(hass, hassio)
# Setup hardware integration for the detected board type
- async def _async_setup_hardware_integration(_: datetime) -> None:
+ async def _async_setup_hardware_integration(_: datetime | None = None) -> None:
"""Set up hardaware integration for the detected board type."""
if (os_info := get_os_info(hass)) is None:
# os info not yet fetched from supervisor, retry later
async_track_point_in_utc_time(
hass,
- _async_setup_hardware_integration,
+ async_setup_hardware_integration_job,
utcnow() + HASSIO_UPDATE_INTERVAL,
)
return
@@ -610,7 +624,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
)
)
- await _async_setup_hardware_integration(datetime.now())
+ async_setup_hardware_integration_job = HassJob(
+ _async_setup_hardware_integration, cancel_on_shutdown=True
+ )
+
+ await _async_setup_hardware_integration()
hass.async_create_task(
hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
@@ -770,7 +788,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
new_data: dict[str, Any] = {}
supervisor_info = get_supervisor_info(self.hass) or {}
- addons_info = get_addons_info(self.hass)
+ addons_info = get_addons_info(self.hass) or {}
addons_stats = get_addons_stats(self.hass)
addons_changelogs = get_addons_changelogs(self.hass)
store_data = get_store(self.hass) or {}
diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py
index d469b66b9e0e..b2cf0040be04 100644
--- a/homeassistant/components/hassio/addon_panel.py
+++ b/homeassistant/components/hassio/addon_panel.py
@@ -30,7 +30,11 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio):
for addon, data in panels.items():
if not data[ATTR_ENABLE]:
continue
- jobs.append(_register_panel(hass, addon, data))
+ jobs.append(
+ asyncio.create_task(
+ _register_panel(hass, addon, data), name=f"register panel {addon}"
+ )
+ )
if jobs:
await asyncio.wait(jobs)
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
index 1dfd5ce53cd6..2bc314f169a4 100644
--- a/homeassistant/components/hassio/const.py
+++ b/homeassistant/components/hassio/const.py
@@ -1,5 +1,5 @@
"""Hass.io const variables."""
-from enum import Enum
+from homeassistant.backports.enum import StrEnum
DOMAIN = "hassio"
@@ -61,6 +61,7 @@ ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent"
ATTR_CHANGELOG = "changelog"
+ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug"
ATTR_STATE = "state"
@@ -76,9 +77,12 @@ DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_REFERENCE = "reference"
+PLACEHOLDER_KEY_COMPONENTS = "components"
+
+ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
-class SupervisorEntityModel(str, Enum):
+class SupervisorEntityModel(StrEnum):
"""Supervisor entity model."""
ADDON = "Home Assistant Add-on"
@@ -86,3 +90,17 @@ class SupervisorEntityModel(str, Enum):
CORE = "Home Assistant Core"
SUPERVIOSR = "Home Assistant Supervisor"
HOST = "Home Assistant Host"
+
+
+class SupervisorIssueContext(StrEnum):
+ """Context for supervisor issues."""
+
+ ADDON = "addon"
+ CORE = "core"
+ DNS_SERVER = "dns_server"
+ MOUNT = "mount"
+ OS = "os"
+ PLUGIN = "plugin"
+ SUPERVISOR = "supervisor"
+ STORE = "store"
+ SYSTEM = "system"
diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py
index 334c7cf719cc..fc92e9309a03 100644
--- a/homeassistant/components/hassio/ingress.py
+++ b/homeassistant/components/hassio/ingress.py
@@ -119,8 +119,8 @@ class HassIOIngress(HomeAssistantView):
# Proxy requests
await asyncio.wait(
[
- _websocket_forward(ws_server, ws_client),
- _websocket_forward(ws_client, ws_server),
+ asyncio.create_task(_websocket_forward(ws_server, ws_client)),
+ asyncio.create_task(_websocket_forward(ws_client, ws_server)),
],
return_when=asyncio.FIRST_COMPLETED,
)
diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py
index ac6af7f3489e..a92fc392fa44 100644
--- a/homeassistant/components/hassio/issues.py
+++ b/homeassistant/components/hassio/issues.py
@@ -35,8 +35,10 @@ from .const import (
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
+ ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_REFERENCE,
UPDATE_KEY_SUPERVISOR,
+ SupervisorIssueContext,
)
from .handler import HassIO, HassioAPIError
@@ -85,8 +87,10 @@ UNHEALTHY_REASONS = {
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
+ "issue_mount_mount_failed",
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
+ ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
}
_LOGGER = logging.getLogger(__name__)
@@ -106,22 +110,22 @@ class Suggestion:
"""Suggestion from Supervisor which resolves an issue."""
uuid: str
- type_: str
- context: str
+ type: str
+ context: SupervisorIssueContext
reference: str | None = None
@property
def key(self) -> str:
"""Get key for suggestion (combination of context and type)."""
- return f"{self.context}_{self.type_}"
+ return f"{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: SuggestionDataType) -> Suggestion:
"""Convert from dictionary representation."""
return cls(
uuid=data["uuid"],
- type_=data["type"],
- context=data["context"],
+ type=data["type"],
+ context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
)
@@ -141,15 +145,15 @@ class Issue:
"""Issue from Supervisor."""
uuid: str
- type_: str
- context: str
+ type: str
+ context: SupervisorIssueContext
reference: str | None = None
suggestions: list[Suggestion] = field(default_factory=list, compare=False)
@property
def key(self) -> str:
"""Get key for issue (combination of context and type)."""
- return f"issue_{self.context}_{self.type_}"
+ return f"issue_{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: IssueDataType) -> Issue:
@@ -157,8 +161,8 @@ class Issue:
suggestions: list[SuggestionDataType] = data.get("suggestions", [])
return cls(
uuid=data["uuid"],
- type_=data["type"],
- context=data["context"],
+ type=data["type"],
+ context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions
@@ -241,6 +245,11 @@ class SupervisorIssues:
self._unsupported_reasons = reasons
+ @property
+ def issues(self) -> set[Issue]:
+ """Get issues."""
+ return set(self._issues.values())
+
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
@@ -262,20 +271,10 @@ class SupervisorIssues:
async def add_issue_from_data(self, data: IssueDataType) -> None:
"""Add issue from data to list after getting latest suggestions."""
try:
- suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[
- ATTR_SUGGESTIONS
- ]
- self.add_issue(
- Issue(
- uuid=data["uuid"],
- type_=data["type"],
- context=data["context"],
- reference=data["reference"],
- suggestions=[
- Suggestion.from_dict(suggestion) for suggestion in suggestions
- ],
- )
- )
+ data["suggestions"] = (
+ await self._client.get_suggestions_for_issue(data["uuid"])
+ )[ATTR_SUGGESTIONS]
+ self.add_issue(Issue.from_dict(data))
except HassioAPIError:
_LOGGER.error(
"Could not get suggestions for supervisor issue %s, skipping it",
diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py
index 50a9b087a7cd..d5e26d4670f4 100644
--- a/homeassistant/components/hassio/repairs.py
+++ b/homeassistant/components/hassio/repairs.py
@@ -10,12 +10,24 @@ from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
-from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE
+from . import get_addons_info, get_issues_info
+from .const import (
+ ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
+ PLACEHOLDER_KEY_COMPONENTS,
+ PLACEHOLDER_KEY_REFERENCE,
+ SupervisorIssueContext,
+)
from .handler import HassioAPIError, async_apply_suggestion
-from .issues import Issue, Suggestion, SupervisorIssues
+from .issues import Issue, Suggestion
SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
+EXTRA_PLACEHOLDERS = {
+ "issue_mount_mount_failed": {
+ "storage_url": "/config/storage",
+ }
+}
+
class SupervisorIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -31,10 +43,8 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property
def issue(self) -> Issue | None:
"""Get associated issue."""
- if not self._issue:
- supervisor_issues: SupervisorIssues = self.hass.data[
- DATA_KEY_SUPERVISOR_ISSUES
- ]
+ supervisor_issues = get_issues_info(self.hass)
+ if not self._issue and supervisor_issues:
self._issue = supervisor_issues.get_issue(self._issue_id)
return self._issue
@@ -42,11 +52,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
- return (
- {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
- if self.issue and self.issue.reference
- else None
- )
+ placeholders = {}
+ if self.issue:
+ placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {})
+ if self.issue.reference:
+ placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
+
+ return placeholders or None
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
"""Return form for suggestion."""
@@ -113,10 +125,49 @@ class SupervisorIssueRepairFlow(RepairsFlow):
return _async_step
+class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
+ """Handler for docker config issue fixing flow."""
+
+ @property
+ def description_placeholders(self) -> dict[str, str] | None:
+ """Get description placeholders for steps."""
+ placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""}
+ supervisor_issues = get_issues_info(self.hass)
+ if supervisor_issues and self.issue:
+ addons = get_addons_info(self.hass) or {}
+ components: list[str] = []
+ for issue in supervisor_issues.issues:
+ if issue.key == self.issue.key or issue.type != self.issue.type:
+ continue
+
+ if issue.context == SupervisorIssueContext.CORE:
+ components.insert(0, "Home Assistant")
+ elif issue.context == SupervisorIssueContext.ADDON:
+ components.append(
+ next(
+ (
+ info["name"]
+ for slug, info in addons.items()
+ if slug == issue.reference
+ ),
+ issue.reference or "",
+ )
+ )
+
+ placeholders[PLACEHOLDER_KEY_COMPONENTS] = "\n- ".join(components)
+
+ return placeholders
+
+
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
+ supervisor_issues = get_issues_info(hass)
+ issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
+ if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
+ return DockerConfigIssueRepairFlow(issue_id)
+
return SupervisorIssueRepairFlow(issue_id)
diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml
index e526074b1a9b..60b547354932 100644
--- a/homeassistant/components/hassio/services.yaml
+++ b/homeassistant/components/hassio/services.yaml
@@ -88,6 +88,12 @@ backup_full:
default: true
selector:
boolean:
+ location:
+ name: Location
+ description: Name of a backup network storage to put backup (or /backup)
+ example: my_backup_mount
+ selector:
+ backup_location:
backup_partial:
name: Create a partial backup.
@@ -128,6 +134,12 @@ backup_partial:
default: true
selector:
boolean:
+ location:
+ name: Location
+ description: Name of a backup network storage to put backup (or /backup)
+ example: my_backup_mount
+ selector:
+ backup_location:
restore_full:
name: Restore from full backup.
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 078aac39a5ba..f9c212f946c9 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -17,16 +17,46 @@
}
},
"issues": {
+ "issue_mount_mount_failed": {
+ "title": "Network storage device failed",
+ "fix_flow": {
+ "step": {
+ "fix_menu": {
+ "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).",
+ "menu_options": {
+ "mount_execute_reload": "Reload",
+ "mount_execute_remove": "Remove"
+ }
+ }
+ },
+ "abort": {
+ "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details."
+ }
+ }
+ },
+ "issue_system_docker_config": {
+ "title": "Restart(s) required",
+ "fix_flow": {
+ "step": {
+ "system_execute_rebuild": {
+ "description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}"
+ }
+ },
+ "abort": {
+ "apply_suggestion_fail": "One or more of the restarts failed. Check the Supervisor logs for more details."
+ }
+ }
+ },
"issue_system_multiple_data_disks": {
"title": "Multiple data disks detected",
"fix_flow": {
"step": {
"system_rename_data_disk": {
- "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system."
+ "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system."
}
},
"abort": {
- "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details."
+ "apply_suggestion_fail": "Could not rename the filesystem. Check the Supervisor logs for more details."
}
}
},
@@ -39,7 +69,7 @@
}
},
"abort": {
- "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details."
+ "apply_suggestion_fail": "Could not reboot the system. Check the Supervisor logs for more details."
}
}
},
diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py
index 5ad8393726f0..459f03edfbbe 100644
--- a/homeassistant/components/hdmi_cec/__init__.py
+++ b/homeassistant/components/hdmi_cec/__init__.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
-from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import discovery, event
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -208,16 +208,18 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901
def _adapter_watchdog(now=None):
_LOGGER.debug("Reached _adapter_watchdog")
- event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog)
+ event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job)
if not adapter.initialized:
_LOGGER.info("Adapter not initialized; Trying to restart")
hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE)
adapter.init()
+ _adapter_watchdog_job = HassJob(_adapter_watchdog, cancel_on_shutdown=True)
+
@callback
def _async_initialized_callback(*_: Any):
"""Add watchdog on initialization."""
- return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog)
+ return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job)
hdmi_network.set_initialized_callback(_async_initialized_callback)
diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py
index 2ce91946f86e..1c728bcc12c3 100644
--- a/homeassistant/components/here_travel_time/__init__.py
+++ b/homeassistant/components/here_travel_time/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
CONF_ARRIVAL_TIME,
@@ -32,8 +32,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
"""Set up HERE Travel Time from a config entry."""
api_key = config_entry.data[CONF_API_KEY]
- arrival = dt.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, ""))
- departure = dt.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, ""))
+ arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, ""))
+ departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, ""))
here_travel_time_config = HERETravelTimeConfig(
destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE),
diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py
index ae8bfc34a42f..dbb17b58336e 100644
--- a/homeassistant/components/here_travel_time/coordinator.py
+++ b/homeassistant/components/here_travel_time/coordinator.py
@@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.location import find_coordinates
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import DistanceConverter
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST
@@ -336,7 +336,7 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None:
def next_datetime(simple_time: time) -> datetime:
"""Take a time like 08:00:00 and combine it with the current date."""
- combined = datetime.combine(dt.start_of_local_day(), simple_time)
+ combined = datetime.combine(dt_util.start_of_local_day(), simple_time)
if combined < datetime.now():
combined = combined + timedelta(days=1)
return combined
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
index 2b02be17e9aa..5b1242423c74 100644
--- a/homeassistant/components/history_stats/sensor.py
+++ b/homeassistant/components/history_stats/sensor.py
@@ -17,6 +17,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_STATE,
CONF_TYPE,
+ CONF_UNIQUE_ID,
PERCENTAGE,
UnitOfTime,
)
@@ -72,6 +73,7 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_DURATION): cv.time_period,
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
}
),
exactly_two_period_keys,
@@ -95,6 +97,7 @@ async def async_setup_platform(
duration: datetime.timedelta | None = config.get(CONF_DURATION)
sensor_type: str = config[CONF_TYPE]
name: str = config[CONF_NAME]
+ unique_id: str | None = config.get(CONF_UNIQUE_ID)
for template in (start, end):
if template is not None:
@@ -105,7 +108,7 @@ async def async_setup_platform(
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise PlatformNotReady from coordinator.last_exception
- async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)])
+ async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name, unique_id)])
class HistoryStatsSensorBase(
@@ -150,11 +153,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
coordinator: HistoryStatsUpdateCoordinator,
sensor_type: str,
name: str,
+ unique_id: str | None,
) -> None:
"""Initialize the HistoryStats sensor."""
super().__init__(coordinator, name)
self._attr_native_unit_of_measurement = UNITS[sensor_type]
self._type = sensor_type
+ self._attr_unique_id = unique_id
self._process_update()
if self._type == CONF_TYPE_TIME:
self._attr_device_class = SensorDeviceClass.DURATION
diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py
index d0464968d4e8..10dc2d360fa2 100644
--- a/homeassistant/components/home_connect/api.py
+++ b/homeassistant/components/home_connect/api.py
@@ -74,6 +74,8 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI):
device = Dryer(self.hass, app)
elif app.type == "Washer":
device = Washer(self.hass, app)
+ elif app.type == "WasherDryer":
+ device = WasherDryer(self.hass, app)
elif app.type == "Dishwasher":
device = Dishwasher(self.hass, app)
elif app.type == "FridgeFreezer":
@@ -358,6 +360,30 @@ class Washer(
}
+class WasherDryer(
+ DeviceWithDoor,
+ DeviceWithOpState,
+ DeviceWithPrograms,
+ DeviceWithRemoteControl,
+ DeviceWithRemoteStart,
+):
+ """WasherDryer class."""
+
+ def get_entity_info(self):
+ """Get a dictionary with infos about the associated entities."""
+ door_entity = self.get_door_entity()
+ remote_control = self.get_remote_control()
+ remote_start = self.get_remote_start()
+ op_state_sensor = self.get_opstate_sensor()
+ program_sensors = self.get_program_sensors()
+ program_switches = self.get_program_switches()
+ return {
+ "binary_sensor": [door_entity, remote_control, remote_start],
+ "switch": program_switches,
+ "sensor": program_sensors + op_state_sensor,
+ }
+
+
class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart):
"""Coffee maker class."""
diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py
index 89b1f23589fd..61dd11dbc6f2 100644
--- a/homeassistant/components/home_connect/switch.py
+++ b/homeassistant/components/home_connect/switch.py
@@ -50,6 +50,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
def __init__(self, device, program_name):
"""Initialize the entity."""
desc = " ".join(["Program", program_name.split(".")[-1]])
+ if device.appliance.type == "WasherDryer":
+ desc = " ".join(
+ ["Program", program_name.split(".")[-3], program_name.split(".")[-1]]
+ )
super().__init__(device, desc)
self.program_name = program_name
self._state = None
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index 06ecd4fe3ef0..55a40e7ba9d1 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -11,6 +11,10 @@
"python_version": {
"title": "Support for Python {current_python_version} is being removed",
"description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking."
+ },
+ "integration_key_no_support": {
+ "title": "This integration does not support YAML configuration",
+ "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but the configuration settings defined in YAML are not actually applied. \n\nTo resolve this: 1. Please remove this integration from your YAML configuration file.\n\n2. Restart Home Assistant."
}
},
"system_health": {
diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py
index d84b04c36527..d0e74d5b04e3 100644
--- a/homeassistant/components/homeassistant/triggers/event.py
+++ b/homeassistant/components/homeassistant/triggers/event.py
@@ -91,7 +91,7 @@ async def async_attach_trigger(
if event_data_schema:
event_data_schema(event.data)
if event_context_schema:
- event_context_schema(event.context.as_dict())
+ event_context_schema(dict(event.context.as_dict()))
except vol.Invalid:
# If event doesn't match, skip event
return False
diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py
index 234f5ae4fed9..8241c1712654 100644
--- a/homeassistant/components/homeassistant_alerts/__init__.py
+++ b/homeassistant/components/homeassistant_alerts/__init__.py
@@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.hassio import get_supervisor_info, is_hassio
from homeassistant.const import EVENT_COMPONENT_LOADED, __version__
from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.issue_registry import (
@@ -28,6 +29,8 @@ DOMAIN = "homeassistant_alerts"
UPDATE_INTERVAL = timedelta(hours=3)
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up alerts."""
diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py
index f3a63a7f7677..057cdd3b0db8 100644
--- a/homeassistant/components/homeassistant_hardware/__init__.py
+++ b/homeassistant/components/homeassistant_hardware/__init__.py
@@ -2,8 +2,12 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
+DOMAIN = "homeassistant_hardware"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
index ff2bf9138f53..34ab9a3cedbf 100644
--- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
+++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py
@@ -5,7 +5,7 @@ from abc import ABC, abstractmethod
import asyncio
import dataclasses
import logging
-from typing import Any
+from typing import Any, Protocol
import voluptuous as vol
import yarl
@@ -19,17 +19,19 @@ from homeassistant.components.hassio import (
hostname_from_addon_slug,
is_hassio,
)
-from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
-from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper
from homeassistant.core import HomeAssistant, callback
-from homeassistant.data_entry_flow import (
- AbortFlow,
- FlowHandler,
- FlowManager,
- FlowResult,
-)
+from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.integration_platform import (
+ async_process_integration_platforms,
+)
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
from homeassistant.helpers.singleton import singleton
+from homeassistant.helpers.storage import Store
from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
@@ -44,17 +46,144 @@ CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
CONF_ADDON_DEVICE = "device"
CONF_ENABLE_MULTI_PAN = "enable_multi_pan"
+DEFAULT_CHANNEL = 15
+DEFAULT_CHANNEL_CHANGE_DELAY = 5 * 60 # Thread recommendation
+
+STORAGE_KEY = "homeassistant_hardware.silabs"
+STORAGE_VERSION_MAJOR = 1
+STORAGE_VERSION_MINOR = 1
+SAVE_DELAY = 10
+
@singleton(DATA_ADDON_MANAGER)
-@callback
-def get_addon_manager(hass: HomeAssistant) -> AddonManager:
+async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager:
"""Get the add-on manager."""
- return AddonManager(
- hass,
- LOGGER,
- "Silicon Labs Multiprotocol",
- SILABS_MULTIPROTOCOL_ADDON_SLUG,
- )
+ manager = MultiprotocolAddonManager(hass)
+ await manager.async_setup()
+ return manager
+
+
+class MultiprotocolAddonManager(AddonManager):
+ """Silicon Labs Multiprotocol add-on manager."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the manager."""
+ super().__init__(
+ hass,
+ LOGGER,
+ "Silicon Labs Multiprotocol",
+ SILABS_MULTIPROTOCOL_ADDON_SLUG,
+ )
+ self._channel: int | None = None
+ self._platforms: dict[str, MultipanProtocol] = {}
+ self._store: Store[dict[str, Any]] = Store(
+ hass,
+ STORAGE_VERSION_MAJOR,
+ STORAGE_KEY,
+ atomic_writes=True,
+ minor_version=STORAGE_VERSION_MINOR,
+ )
+
+ async def async_setup(self) -> None:
+ """Set up the manager."""
+ await async_process_integration_platforms(
+ self._hass, "silabs_multiprotocol", self._register_multipan_platform
+ )
+ await self.async_load()
+
+ async def _register_multipan_platform(
+ self, hass: HomeAssistant, integration_domain: str, platform: MultipanProtocol
+ ) -> None:
+ """Register a multipan platform."""
+ self._platforms[integration_domain] = platform
+ if self._channel is not None or not await platform.async_using_multipan(hass):
+ return
+
+ new_channel = await platform.async_get_channel(hass)
+ if new_channel is None:
+ return
+
+ _LOGGER.info(
+ "Setting multipan channel to %s (source: '%s')",
+ new_channel,
+ integration_domain,
+ )
+ self.async_set_channel(new_channel)
+
+ async def async_change_channel(
+ self, channel: int, delay: float
+ ) -> list[asyncio.Task]:
+ """Change the channel and notify platforms."""
+ self.async_set_channel(channel)
+
+ tasks = []
+
+ for platform in self._platforms.values():
+ if not await platform.async_using_multipan(self._hass):
+ continue
+ task = await platform.async_change_channel(self._hass, channel, delay)
+ if not task:
+ continue
+ tasks.append(task)
+
+ return tasks
+
+ @callback
+ def async_get_channel(self) -> int | None:
+ """Get the channel."""
+ return self._channel
+
+ @callback
+ def async_set_channel(self, channel: int) -> None:
+ """Set the channel without notifying platforms.
+
+ This must only be called when first initializing the manager.
+ """
+ self._channel = channel
+ self.async_schedule_save()
+
+ async def async_load(self) -> None:
+ """Load the store."""
+ data = await self._store.async_load()
+
+ if data is not None:
+ self._channel = data["channel"]
+
+ @callback
+ def async_schedule_save(self) -> None:
+ """Schedule saving the store."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]:
+ """Return data to store in a file."""
+ data: dict[str, Any] = {}
+ data["channel"] = self._channel
+ return data
+
+
+class MultipanProtocol(Protocol):
+ """Define the format of multipan platforms."""
+
+ async def async_change_channel(
+ self, hass: HomeAssistant, channel: int, delay: float
+ ) -> asyncio.Task | None:
+ """Set the channel to be used.
+
+ Does nothing if not configured or the multiprotocol add-on is not used.
+ """
+
+ async def async_get_channel(self, hass: HomeAssistant) -> int | None:
+ """Return the channel.
+
+ Returns None if not configured or the multiprotocol add-on is not used.
+ """
+
+ async def async_using_multipan(self, hass: HomeAssistant) -> bool:
+ """Return if the multiprotocol device is used.
+
+ Returns False if not configured.
+ """
@dataclasses.dataclass
@@ -82,20 +211,23 @@ def is_multiprotocol_url(url: str) -> bool:
return parsed.host == hostname
-class BaseMultiPanFlow(FlowHandler, ABC):
- """Support configuring the Silicon Labs Multiprotocol add-on."""
+class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
+ """Handle an options flow for the Silicon Labs Multiprotocol add-on."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
+ """Set up the options flow."""
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.zha.radio_manager import (
+ ZhaMultiPANMigrationHelper,
+ )
- def __init__(self) -> None:
- """Set up flow instance."""
# If we install the add-on we should uninstall it on entry remove.
self.install_task: asyncio.Task | None = None
self.start_task: asyncio.Task | None = None
self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None
-
- @property
- @abstractmethod
- def flow_manager(self) -> FlowManager:
- """Return the flow manager of the flow."""
+ self.config_entry = config_entry
+ self.original_addon_config: dict[str, Any] | None = None
+ self.revert_reason: str | None = None
@abstractmethod
async def _async_serial_port_settings(self) -> SerialPortSettings:
@@ -117,85 +249,14 @@ class BaseMultiPanFlow(FlowHandler, ABC):
def _zha_name(self) -> str:
"""Return the ZHA name."""
- async def async_step_install_addon(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Install Silicon Labs Multiprotocol add-on."""
- if not self.install_task:
- self.install_task = self.hass.async_create_task(self._async_install_addon())
- return self.async_show_progress(
- step_id="install_addon", progress_action="install_addon"
- )
-
- try:
- await self.install_task
- except AddonError as err:
- self.install_task = None
- _LOGGER.error(err)
- return self.async_show_progress_done(next_step_id="install_failed")
-
- self.install_task = None
-
- return self.async_show_progress_done(next_step_id="configure_addon")
-
- async def async_step_install_failed(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Add-on installation failed."""
- return self.async_abort(reason="addon_install_failed")
-
- async def async_step_start_addon(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Start Silicon Labs Multiprotocol add-on."""
- if not self.start_task:
- self.start_task = self.hass.async_create_task(self._async_start_addon())
- return self.async_show_progress(
- step_id="start_addon", progress_action="start_addon"
- )
-
- try:
- await self.start_task
- except (AddonError, AbortFlow) as err:
- self.start_task = None
- _LOGGER.error(err)
- return self.async_show_progress_done(next_step_id="start_failed")
-
- self.start_task = None
- return self.async_show_progress_done(next_step_id="finish_addon_setup")
-
- async def async_step_start_failed(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Add-on start failed."""
- return self.async_abort(reason="addon_start_failed")
-
- async def _async_start_addon(self) -> None:
- """Start Silicon Labs Multiprotocol add-on."""
- addon_manager: AddonManager = get_addon_manager(self.hass)
- try:
- await addon_manager.async_schedule_start_addon()
- finally:
- # Continue the flow after show progress when the task is done.
- self.hass.async_create_task(
- self.flow_manager.async_configure(flow_id=self.flow_id)
- )
-
- @abstractmethod
- async def async_step_configure_addon(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Configure the Silicon Labs Multiprotocol add-on."""
-
- @abstractmethod
- async def async_step_finish_addon_setup(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Finish setup of the Silicon Labs Multiprotocol add-on."""
+ @property
+ def flow_manager(self) -> config_entries.OptionsFlowManager:
+ """Return the correct flow manager."""
+ return self.hass.config_entries.options
async def _async_get_addon_info(self) -> AddonInfo:
"""Return and cache Silicon Labs Multiprotocol add-on info."""
- addon_manager: AddonManager = get_addon_manager(self.hass)
+ addon_manager: AddonManager = await get_addon_manager(self.hass)
try:
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
except AddonError as err:
@@ -206,7 +267,7 @@ class BaseMultiPanFlow(FlowHandler, ABC):
async def _async_set_addon_config(self, config: dict) -> None:
"""Set Silicon Labs Multiprotocol add-on config."""
- addon_manager: AddonManager = get_addon_manager(self.hass)
+ addon_manager: AddonManager = await get_addon_manager(self.hass)
try:
await addon_manager.async_set_addon_options(config)
except AddonError as err:
@@ -215,7 +276,7 @@ class BaseMultiPanFlow(FlowHandler, ABC):
async def _async_install_addon(self) -> None:
"""Install the Silicon Labs Multiprotocol add-on."""
- addon_manager: AddonManager = get_addon_manager(self.hass)
+ addon_manager: AddonManager = await get_addon_manager(self.hass)
try:
await addon_manager.async_schedule_install_addon()
finally:
@@ -224,22 +285,6 @@ class BaseMultiPanFlow(FlowHandler, ABC):
self.flow_manager.async_configure(flow_id=self.flow_id)
)
-
-class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
- """Handle an options flow for the Silicon Labs Multiprotocol add-on."""
-
- def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
- """Set up the options flow."""
- super().__init__()
- self.config_entry = config_entry
- self.original_addon_config: dict[str, Any] | None = None
- self.revert_reason: str | None = None
-
- @property
- def flow_manager(self) -> config_entries.OptionsFlowManager:
- """Return the correct flow manager."""
- return self.hass.config_entries.options
-
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -276,10 +321,50 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
return await self.async_step_install_addon()
+ async def async_step_install_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Install Silicon Labs Multiprotocol add-on."""
+ if not self.install_task:
+ self.install_task = self.hass.async_create_task(self._async_install_addon())
+ return self.async_show_progress(
+ step_id="install_addon", progress_action="install_addon"
+ )
+
+ try:
+ await self.install_task
+ except AddonError as err:
+ self.install_task = None
+ _LOGGER.error(err)
+ return self.async_show_progress_done(next_step_id="install_failed")
+
+ self.install_task = None
+
+ return self.async_show_progress_done(next_step_id="configure_addon")
+
+ async def async_step_install_failed(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Add-on installation failed."""
+ return self.async_abort(reason="addon_install_failed")
+
async def async_step_configure_addon(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Configure the Silicon Labs Multiprotocol add-on."""
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
+
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.zha.radio_manager import (
+ ZhaMultiPANMigrationHelper,
+ )
+
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.zha.silabs_multiprotocol import (
+ async_get_channel as async_get_zha_channel,
+ )
+
addon_info = await self._async_get_addon_info()
addon_config = addon_info.options
@@ -291,6 +376,8 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
**dataclasses.asdict(serial_port_settings),
}
+ multipan_channel = DEFAULT_CHANNEL
+
# Initiate ZHA migration
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
@@ -314,6 +401,13 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
_LOGGER.exception("Unexpected exception during ZHA migration")
raise AbortFlow("zha_migration_failed") from err
+ if (zha_channel := await async_get_zha_channel(self.hass)) is not None:
+ multipan_channel = zha_channel
+
+ # Initialize the shared channel
+ multipan_manager = await get_addon_manager(self.hass)
+ multipan_manager.async_set_channel(multipan_channel)
+
if new_addon_config != addon_config:
# Copy the add-on config to keep the objects separate.
self.original_addon_config = dict(addon_config)
@@ -322,6 +416,43 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
return await self.async_step_start_addon()
+ async def async_step_start_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Start Silicon Labs Multiprotocol add-on."""
+ if not self.start_task:
+ self.start_task = self.hass.async_create_task(self._async_start_addon())
+ return self.async_show_progress(
+ step_id="start_addon", progress_action="start_addon"
+ )
+
+ try:
+ await self.start_task
+ except (AddonError, AbortFlow) as err:
+ self.start_task = None
+ _LOGGER.error(err)
+ return self.async_show_progress_done(next_step_id="start_failed")
+
+ self.start_task = None
+ return self.async_show_progress_done(next_step_id="finish_addon_setup")
+
+ async def async_step_start_failed(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Add-on start failed."""
+ return self.async_abort(reason="addon_start_failed")
+
+ async def _async_start_addon(self) -> None:
+ """Start Silicon Labs Multiprotocol add-on."""
+ addon_manager: AddonManager = await get_addon_manager(self.hass)
+ try:
+ await addon_manager.async_schedule_start_addon()
+ finally:
+ # Continue the flow after show progress when the task is done.
+ self.hass.async_create_task(
+ self.flow_manager.async_configure(flow_id=self.flow_id)
+ )
+
async def async_step_finish_addon_setup(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -349,9 +480,73 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow):
serial_device = (await self._async_serial_port_settings()).device
if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device:
- return await self.async_step_show_revert_guide()
+ return await self.async_step_show_addon_menu()
return await self.async_step_addon_installed_other_device()
+ async def async_step_show_addon_menu(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Show menu options for the addon."""
+ return self.async_show_menu(
+ step_id="addon_menu",
+ menu_options=[
+ "reconfigure_addon",
+ "uninstall_addon",
+ ],
+ )
+
+ async def async_step_reconfigure_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Reconfigure the addon."""
+ multipan_manager = await get_addon_manager(self.hass)
+
+ if user_input is None:
+ channels = [str(x) for x in range(11, 27)]
+ suggested_channel = DEFAULT_CHANNEL
+ if (channel := multipan_manager.async_get_channel()) is not None:
+ suggested_channel = channel
+ data_schema = vol.Schema(
+ {
+ vol.Required(
+ "channel",
+ description={"suggested_value": str(suggested_channel)},
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=channels, mode=SelectSelectorMode.DROPDOWN
+ )
+ )
+ }
+ )
+ return self.async_show_form(
+ step_id="reconfigure_addon", data_schema=data_schema
+ )
+
+ # Change the shared channel
+ await multipan_manager.async_change_channel(
+ int(user_input["channel"]), DEFAULT_CHANNEL_CHANGE_DELAY
+ )
+ return await self.async_step_notify_channel_change()
+
+ async def async_step_notify_channel_change(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Notify that the channel change will take about five minutes."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="notify_channel_change",
+ description_placeholders={
+ "delay_minutes": str(DEFAULT_CHANNEL_CHANGE_DELAY // 60)
+ },
+ )
+ return self.async_create_entry(title="", data={})
+
+ async def async_step_uninstall_addon(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Uninstall the addon (not implemented)."""
+ return await self.async_step_show_revert_guide()
+
async def async_step_show_revert_guide(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -378,7 +573,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None:
if not is_hassio(hass):
return
- addon_manager: AddonManager = get_addon_manager(hass)
+ addon_manager: AddonManager = await get_addon_manager(hass)
try:
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
except AddonError as err:
@@ -405,7 +600,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) ->
if not is_hassio(hass):
return False
- addon_manager: AddonManager = get_addon_manager(hass)
+ addon_manager: AddonManager = await get_addon_manager(hass)
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
if addon_info.state != AddonState.RUNNING:
diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json
index 47549794fc8f..605013975572 100644
--- a/homeassistant/components/homeassistant_hardware/strings.json
+++ b/homeassistant/components/homeassistant_hardware/strings.json
@@ -12,15 +12,34 @@
"addon_installed_other_device": {
"title": "Multiprotocol support is already enabled for another device"
},
+ "addon_menu": {
+ "menu_options": {
+ "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]",
+ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
+ }
+ },
"install_addon": {
"title": "The Silicon Labs Multiprotocol add-on installation has started"
},
+ "notify_channel_change": {
+ "title": "Channel change initiated",
+ "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes."
+ },
+ "reconfigure_addon": {
+ "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support",
+ "data": {
+ "channel": "Channel"
+ }
+ },
"show_revert_guide": {
"title": "Multiprotocol support is enabled for this device",
"description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio"
},
"start_addon": {
"title": "The Silicon Labs Multiprotocol add-on is starting."
+ },
+ "uninstall_addon": {
+ "title": "Remove IEEE 802.15.4 radio multiprotocol support."
}
},
"error": {
diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json
index 970f9d97a4c1..415df2092a15 100644
--- a/homeassistant/components/homeassistant_sky_connect/strings.json
+++ b/homeassistant/components/homeassistant_sky_connect/strings.json
@@ -11,15 +11,34 @@
"addon_installed_other_device": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]"
},
+ "addon_menu": {
+ "menu_options": {
+ "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]",
+ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]"
+ }
+ },
"install_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
},
+ "notify_channel_change": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
+ },
+ "reconfigure_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]",
+ "data": {
+ "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]"
+ }
+ },
"show_revert_guide": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
+ },
+ "uninstall_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
},
"error": {
diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json
index d97b01c7c843..c1069a7e755a 100644
--- a/homeassistant/components/homeassistant_yellow/strings.json
+++ b/homeassistant/components/homeassistant_yellow/strings.json
@@ -11,6 +11,12 @@
"addon_installed_other_device": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]"
},
+ "addon_menu": {
+ "menu_options": {
+ "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]",
+ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]"
+ }
+ },
"hardware_settings": {
"title": "Configure hardware settings",
"data": {
@@ -22,6 +28,10 @@
"install_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
},
+ "notify_channel_change": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
+ },
"main_menu": {
"menu_options": {
"hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]",
@@ -36,12 +46,21 @@
"reboot_now": "Reboot now"
}
},
+ "reconfigure_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]",
+ "data": {
+ "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]"
+ }
+ },
"show_revert_guide": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
+ },
+ "uninstall_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
},
"error": {
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
index fbe6f08bc75f..73eb699007c7 100644
--- a/homeassistant/components/homekit_controller/cover.py
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -216,6 +216,26 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity):
tilt_position = self.service.value(
CharacteristicsTypes.HORIZONTAL_TILT_CURRENT
)
+ # Recalculate to convert from arcdegree scale to percentage scale.
+ if self.is_vertical_tilt:
+ scale = 0.9
+ if (
+ self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].minValue == -90
+ and self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].maxValue
+ == 0
+ ):
+ scale = -0.9
+ tilt_position = int(tilt_position / scale)
+ elif self.is_horizontal_tilt:
+ scale = 0.9
+ if (
+ self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue
+ == -90
+ and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue
+ == 0
+ ):
+ scale = -0.9
+ tilt_position = int(tilt_position / scale)
return tilt_position
async def async_stop_cover(self, **kwargs: Any) -> None:
@@ -241,10 +261,29 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity):
"""Move the cover tilt to a specific position."""
tilt_position = kwargs[ATTR_TILT_POSITION]
if self.is_vertical_tilt:
+ # Recalculate to convert from percentage scale to arcdegree scale.
+ scale = 0.9
+ if (
+ self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].minValue == -90
+ and self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].maxValue
+ == 0
+ ):
+ scale = -0.9
+ tilt_position = int(tilt_position * scale)
await self.async_put_characteristics(
{CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position}
)
elif self.is_horizontal_tilt:
+ # Recalculate to convert from percentage scale to arcdegree scale.
+ scale = 0.9
+ if (
+ self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue
+ == -90
+ and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue
+ == 0
+ ):
+ scale = -0.9
+ tilt_position = int(tilt_position * scale)
await self.async_put_characteristics(
{CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position}
)
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index 9db26d4c8e07..89261df8751a 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==2.6.3"],
+ "requirements": ["aiohomekit==2.6.4"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py
index ff5448822894..f5cce1d890a3 100644
--- a/homeassistant/components/honeywell/__init__.py
+++ b/homeassistant/components/honeywell/__init__.py
@@ -67,8 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
asyncio.TimeoutError,
) as ex:
raise ConfigEntryNotReady(
- "Failed to initialize the Honeywell client: "
- "Connection error: maybe you have exceeded the API rate limit?"
+ "Failed to initialize the Honeywell client: Connection error"
) from ex
loc_id = config_entry.data.get(CONF_LOC_ID)
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index 2d306ba5ee59..fda8717c3dde 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -365,7 +365,7 @@ class HomeAssistantHTTP:
class_name = view.__class__.__name__
raise AttributeError(f'{class_name} missing required attribute "name"')
- view.register(self.app, self.app.router)
+ view.register(self.hass, self.app, self.app.router)
def register_redirect(
self,
diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py
index f2cfe0674047..77ae80b62ffa 100644
--- a/homeassistant/components/http/auth.py
+++ b/homeassistant/components/http/auth.py
@@ -6,11 +6,13 @@ from datetime import timedelta
from ipaddress import ip_address
import logging
import secrets
+import time
from typing import Any, Final
from aiohttp import hdrs
from aiohttp.web import Application, Request, StreamResponse, middleware
import jwt
+from jwt import api_jws
from yarl import URL
from homeassistant.auth import jwt_wrapper
@@ -18,8 +20,8 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.storage import Store
-from homeassistant.util import dt as dt_util
from homeassistant.util.network import is_local
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
@@ -60,20 +62,19 @@ def async_sign_path(
refresh_token_id = hass.data[STORAGE_KEY]
url = URL(path)
- now = dt_util.utcnow()
+ now_timestamp = int(time.time())
+ expiration_timestamp = now_timestamp + int(expiration.total_seconds())
params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS]
- encoded = jwt.encode(
+ json_payload = json_bytes(
{
"iss": refresh_token_id,
"path": url.path,
"params": params,
- "iat": now,
- "exp": now + expiration,
- },
- secret,
- algorithm="HS256",
+ "iat": now_timestamp,
+ "exp": expiration_timestamp,
+ }
)
-
+ encoded = api_jws.encode(json_payload, secret, "HS256")
params.append((SIGN_QUERY_PARAM, encoded))
url = url.with_query(params)
return f"{url.path}?{url.query_string}"
@@ -105,11 +106,11 @@ def async_user_not_allowed_do_auth(
return "User is local only"
try:
- remote = ip_address(request.remote) # type: ignore[arg-type]
+ remote_address = ip_address(request.remote) # type: ignore[arg-type]
except ValueError:
return "Invalid remote IP"
- if is_local(remote):
+ if is_local(remote_address):
return None
return "User cannot authenticate remotely"
diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py
index d39fca287829..abdcfe466c1a 100644
--- a/homeassistant/components/http/view.py
+++ b/homeassistant/components/http/view.py
@@ -19,7 +19,7 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
-from homeassistant.core import Context, is_callback
+from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.helpers.json import (
find_paths_unserializable_data,
json_bytes,
@@ -27,7 +27,7 @@ from homeassistant.helpers.json import (
)
from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data
-from .const import KEY_AUTHENTICATED, KEY_HASS
+from .const import KEY_AUTHENTICATED
_LOGGER = logging.getLogger(__name__)
@@ -88,7 +88,9 @@ class HomeAssistantView:
data["code"] = message_code
return self.json(data, status_code, headers=headers)
- def register(self, app: web.Application, router: web.UrlDispatcher) -> None:
+ def register(
+ self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher
+ ) -> None:
"""Register the view with a router."""
assert self.url is not None, "No url set for view"
urls = [self.url] + self.extra_urls
@@ -98,7 +100,7 @@ class HomeAssistantView:
if not (handler := getattr(self, method, None)):
continue
- handler = request_handler_factory(self, handler)
+ handler = request_handler_factory(hass, self, handler)
for url in urls:
routes.append(router.add_route(method, url, handler))
@@ -115,16 +117,17 @@ class HomeAssistantView:
def request_handler_factory(
- view: HomeAssistantView, handler: Callable
+ hass: HomeAssistant, view: HomeAssistantView, handler: Callable
) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
"""Wrap the handler classes."""
- assert asyncio.iscoroutinefunction(handler) or is_callback(
+ is_coroutinefunction = asyncio.iscoroutinefunction(handler)
+ assert is_coroutinefunction or is_callback(
handler
), "Handler should be a coroutine or a callback."
async def handle(request: web.Request) -> web.StreamResponse:
"""Handle incoming request."""
- if request.app[KEY_HASS].is_stopping:
+ if hass.is_stopping:
return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE)
authenticated = request.get(KEY_AUTHENTICATED, False)
@@ -132,18 +135,19 @@ def request_handler_factory(
if view.requires_auth and not authenticated:
raise HTTPUnauthorized()
- _LOGGER.debug(
- "Serving %s to %s (auth: %s)",
- request.path,
- request.remote,
- authenticated,
- )
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ _LOGGER.debug(
+ "Serving %s to %s (auth: %s)",
+ request.path,
+ request.remote,
+ authenticated,
+ )
try:
- result = handler(request, **request.match_info)
-
- if asyncio.iscoroutine(result):
- result = await result
+ if is_coroutinefunction:
+ result = await handler(request, **request.match_info)
+ else:
+ result = handler(request, **request.match_info)
except vol.Invalid as err:
raise HTTPBadRequest() from err
except exceptions.ServiceNotFound as err:
@@ -156,21 +160,20 @@ def request_handler_factory(
return result
status_code = HTTPStatus.OK
-
if isinstance(result, tuple):
result, status_code = result
if isinstance(result, bytes):
- bresult = result
- elif isinstance(result, str):
- bresult = result.encode("utf-8")
- elif result is None:
- bresult = b""
- else:
- raise TypeError(
- f"Result should be None, string, bytes or StreamResponse. Got: {result}"
- )
+ return web.Response(body=result, status=status_code)
- return web.Response(body=bresult, status=status_code)
+ if isinstance(result, str):
+ return web.Response(text=result, status=status_code)
+
+ if result is None:
+ return web.Response(body=b"", status=status_code)
+
+ raise TypeError(
+ f"Result should be None, string, bytes or StreamResponse. Got: {result}"
+ )
return handle
diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py
index 5e5b2c8dc946..95197dcbb49f 100644
--- a/homeassistant/components/huawei_lte/__init__.py
+++ b/homeassistant/components/huawei_lte/__init__.py
@@ -326,7 +326,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Huawei LTE component from config entry."""
url = entry.data[CONF_URL]
- def get_connection() -> Connection:
+ def _connect() -> Connection:
"""Set up a connection."""
if entry.options.get(CONF_UNAUTHENTICATED_MODE):
_LOGGER.debug("Connecting in unauthenticated mode, reduced feature set")
@@ -341,7 +341,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return connection
try:
- connection = await hass.async_add_executor_job(get_connection)
+ connection = await hass.async_add_executor_job(_connect)
except LoginErrorInvalidCredentialsException as ex:
raise ConfigEntryAuthFailed from ex
except Timeout as ex:
diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py
index d4aa170f70d4..f6c3b69ddeb6 100644
--- a/homeassistant/components/huawei_lte/config_flow.py
+++ b/homeassistant/components/huawei_lte/config_flow.py
@@ -111,7 +111,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors or {},
)
- async def _try_connect(
+ async def _connect(
self, user_input: dict[str, Any], errors: dict[str, str]
) -> Connection | None:
"""Try connecting with given data."""
@@ -149,11 +149,11 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return conn
@staticmethod
- def _logout(conn: Connection) -> None:
+ def _disconnect(conn: Connection) -> None:
try:
- conn.user_session.user.logout() # type: ignore[union-attr]
+ conn.close()
except Exception: # pylint: disable=broad-except
- _LOGGER.debug("Could not logout", exc_info=True)
+ _LOGGER.debug("Disconnect error", exc_info=True)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -197,7 +197,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
wlan_settings = {}
return device_info, wlan_settings
- conn = await self._try_connect(user_input, errors)
+ conn = await self._connect(user_input, errors)
if errors:
return await self._async_show_user_form(
user_input=user_input, errors=errors
@@ -207,7 +207,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
info, wlan_settings = await self.hass.async_add_executor_job(
get_device_info, conn
)
- await self.hass.async_add_executor_job(self._logout, conn)
+ await self.hass.async_add_executor_job(self._disconnect, conn)
user_input.update(
{
@@ -298,9 +298,9 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
new_data = {**entry.data, **user_input}
errors: dict[str, str] = {}
- conn = await self._try_connect(new_data, errors)
+ conn = await self._connect(new_data, errors)
if conn:
- await self.hass.async_add_executor_job(self._logout, conn)
+ await self.hass.async_add_executor_job(self._disconnect, conn)
if errors:
return await self._async_show_reauth_form(
user_input=user_input, errors=errors
diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py
index 705269292771..f63cc4aac397 100644
--- a/homeassistant/components/huawei_lte/sensor.py
+++ b/homeassistant/components/huawei_lte/sensor.py
@@ -574,7 +574,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
{"0": "Auto", "1": "Manual"}.get(x),
None,
),
- entity_category=EntityCategory.CONFIG,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
},
),
@@ -596,7 +596,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
}.get(x),
None,
),
- entity_category=EntityCategory.CONFIG,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
},
),
diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json
index 0fca7c0a0a20..015b3c08e9a4 100644
--- a/homeassistant/components/humidifier/strings.json
+++ b/homeassistant/components/humidifier/strings.json
@@ -26,6 +26,34 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
+ },
+ "state_attributes": {
+ "available_modes": {
+ "name": "Available modes"
+ },
+ "humidity": {
+ "name": "Target humidity"
+ },
+ "max_humidity": {
+ "name": "Max target humidity"
+ },
+ "min_humidity": {
+ "name": "Min target humidity"
+ },
+ "mode": {
+ "name": "Mode",
+ "state": {
+ "normal": "Normal",
+ "eco": "Eco",
+ "away": "Away",
+ "boost": "Boost",
+ "comfort": "Comfort",
+ "home": "Home",
+ "sleep": "Sleep",
+ "auto": "Auto",
+ "baby": "Baby"
+ }
+ }
}
},
"dehumidifier": {
@@ -34,5 +62,11 @@
"humidifier": {
"name": "[%key:component::humidifier::entity_component::_::name%]"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py
index 36b1b5f927fa..a50b2c4d09b6 100644
--- a/homeassistant/components/hvv_departures/binary_sensor.py
+++ b/homeassistant/components/hvv_departures/binary_sensor.py
@@ -88,7 +88,7 @@ async def async_setup_entry(
so entities can quickly look up their data.
"""
- payload = {"station": station}
+ payload = {"station": {"id": station["id"], "type": station["type"]}}
try:
async with async_timeout.timeout(10):
diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json
index c6d3060b4ee7..c18777613e81 100644
--- a/homeassistant/components/hvv_departures/manifest.json
+++ b/homeassistant/components/hvv_departures/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hvv_departures",
"iot_class": "cloud_polling",
"loggers": ["pygti"],
- "requirements": ["pygti==0.9.3"]
+ "requirements": ["pygti==0.9.4"]
}
diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py
index dfc69e517101..0669289c1bb4 100644
--- a/homeassistant/components/hvv_departures/sensor.py
+++ b/homeassistant/components/hvv_departures/sensor.py
@@ -84,8 +84,10 @@ class HVVDepartureSensor(SensorEntity):
departure_time_tz_berlin = departure_time.astimezone(BERLIN_TIME_ZONE)
+ station = self.config_entry.data[CONF_STATION]
+
payload = {
- "station": self.config_entry.data[CONF_STATION],
+ "station": {"id": station["id"], "type": station["type"]},
"time": {
"date": departure_time_tz_berlin.strftime("%d.%m.%Y"),
"time": departure_time_tz_berlin.strftime("%H:%M"),
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index a99c19cd46a1..e09cabb74fc7 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -1,6 +1,5 @@
"""Support for Hydrawise cloud."""
-from datetime import timedelta
-import logging
+
from hydrawiser.core import Hydrawiser
from requests.exceptions import ConnectTimeout, HTTPError
@@ -8,29 +7,12 @@ import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
-from homeassistant.helpers.entity import Entity, EntityDescription
-from homeassistant.helpers.event import track_time_interval
from homeassistant.helpers.typing import ConfigType
-_LOGGER = logging.getLogger(__name__)
-
-ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60]
-
-CONF_WATERING_TIME = "watering_minutes"
-
-NOTIFICATION_ID = "hydrawise_notification"
-NOTIFICATION_TITLE = "Hydrawise Setup"
-
-DATA_HYDRAWISE = "hydrawise"
-DOMAIN = "hydrawise"
-DEFAULT_WATERING_TIME = 15
-
-SCAN_INTERVAL = timedelta(seconds=120)
-
-SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
+from .const import DOMAIN, LOGGER, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL
+from .coordinator import HydrawiseDataUpdateCoordinator
CONFIG_SCHEMA = vol.Schema(
{
@@ -45,70 +27,36 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Hunter Hydrawise component."""
conf = config[DOMAIN]
access_token = conf[CONF_ACCESS_TOKEN]
scan_interval = conf.get(CONF_SCAN_INTERVAL)
try:
- hydrawise = Hydrawiser(user_token=access_token)
- hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise)
+ hydrawise = await hass.async_add_executor_job(Hydrawiser, access_token)
except (ConnectTimeout, HTTPError) as ex:
- _LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex))
- persistent_notification.create(
- hass,
- f"Error: {ex}
You will need to restart hass after fixing.",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
+ LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex))
+ _show_failure_notification(hass, str(ex))
return False
- def hub_refresh(event_time):
- """Call Hydrawise hub to refresh information."""
- _LOGGER.debug("Updating Hydrawise Hub component")
- hass.data[DATA_HYDRAWISE].data.update_controller_info()
- dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE)
+ if not hydrawise.current_controller:
+ LOGGER.error("Failed to fetch Hydrawise data")
+ _show_failure_notification(hass, "Failed to fetch Hydrawise data.")
+ return False
- # Call the Hydrawise API to refresh updates
- track_time_interval(hass, hub_refresh, scan_interval)
+ hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval)
+
+ # NOTE: We don't need to call async_config_entry_first_refresh() because
+ # data is fetched when the Hydrawiser object is instantiated.
return True
-class HydrawiseHub:
- """Representation of a base Hydrawise device."""
-
- def __init__(self, data):
- """Initialize the entity."""
- self.data = data
-
-
-class HydrawiseEntity(Entity):
- """Entity class for Hydrawise devices."""
-
- _attr_attribution = "Data provided by hydrawise.com"
-
- def __init__(self, data, description: EntityDescription) -> None:
- """Initialize the Hydrawise entity."""
- self.entity_description = description
- self.data = data
- self._attr_name = f"{self.data['name']} {description.name}"
-
- async def async_added_to_hass(self):
- """Register callbacks."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback
- )
- )
-
- @callback
- def _update_callback(self):
- """Call update method."""
- self.async_schedule_update_ha_state(True)
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- return {"identifier": self.data.get("relay")}
+def _show_failure_notification(hass: HomeAssistant, error: str) -> None:
+ persistent_notification.create(
+ hass,
+ f"Error: {error}
You will need to restart hass after fixing.",
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID,
+ )
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index 3b496355a56b..2986bbb170ea 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -1,8 +1,7 @@
"""Support for Hydrawise sprinkler binary sensors."""
from __future__ import annotations
-import logging
-
+from hydrawiser.core import Hydrawiser
import voluptuous as vol
from homeassistant.components.binary_sensor import (
@@ -12,14 +11,14 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DATA_HYDRAWISE, HydrawiseEntity
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN, LOGGER
+from .coordinator import HydrawiseDataUpdateCoordinator
+from .entity import HydrawiseEntity
BINARY_SENSOR_STATUS = BinarySensorEntityDescription(
key="status",
@@ -55,24 +54,30 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up a sensor for a Hydrawise device."""
- hydrawise = hass.data[DATA_HYDRAWISE].data
+ coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN]
+ hydrawise: Hydrawiser = coordinator.api
monitored_conditions = config[CONF_MONITORED_CONDITIONS]
entities = []
if BINARY_SENSOR_STATUS.key in monitored_conditions:
entities.append(
- HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS)
+ HydrawiseBinarySensor(
+ data=hydrawise.current_controller,
+ coordinator=coordinator,
+ description=BINARY_SENSOR_STATUS,
+ )
)
# create a sensor for each zone
- entities.extend(
- [
- HydrawiseBinarySensor(zone, description)
- for zone in hydrawise.relays
- for description in BINARY_SENSOR_TYPES
- if description.key in monitored_conditions
- ]
- )
+ for zone in hydrawise.relays:
+ for description in BINARY_SENSOR_TYPES:
+ if description.key not in monitored_conditions:
+ continue
+ entities.append(
+ HydrawiseBinarySensor(
+ data=zone, coordinator=coordinator, description=description
+ )
+ )
add_entities(entities, True)
@@ -80,12 +85,13 @@ def setup_platform(
class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity):
"""A sensor implementation for Hydrawise device."""
- def update(self) -> None:
+ @callback
+ def _handle_coordinator_update(self) -> None:
"""Get the latest data and updates the state."""
- _LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name)
- mydata = self.hass.data[DATA_HYDRAWISE].data
+ LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name)
if self.entity_description.key == "status":
- self._attr_is_on = mydata.status == "All good!"
+ self._attr_is_on = self.coordinator.api.status == "All good!"
elif self.entity_description.key == "is_watering":
- relay_data = mydata.relays[self.data["relay"] - 1]
+ relay_data = self.coordinator.api.relays[self.data["relay"] - 1]
self._attr_is_on = relay_data["timestr"] == "Now"
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py
new file mode 100644
index 000000000000..515fdaec2b15
--- /dev/null
+++ b/homeassistant/components/hydrawise/const.py
@@ -0,0 +1,19 @@
+"""Constants for the Hydrawise integration."""
+
+from datetime import timedelta
+import logging
+
+LOGGER = logging.getLogger(__package__)
+
+ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60]
+CONF_WATERING_TIME = "watering_minutes"
+
+NOTIFICATION_ID = "hydrawise_notification"
+NOTIFICATION_TITLE = "Hydrawise Setup"
+
+DOMAIN = "hydrawise"
+DEFAULT_WATERING_TIME = 15
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py
new file mode 100644
index 000000000000..ea2e2dd2c4c0
--- /dev/null
+++ b/homeassistant/components/hydrawise/coordinator.py
@@ -0,0 +1,29 @@
+"""DataUpdateCoordinator for the Hydrawise integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+
+from hydrawiser.core import Hydrawiser
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER
+
+
+class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]):
+ """The Hydrawise Data Update Coordinator."""
+
+ def __init__(
+ self, hass: HomeAssistant, api: Hydrawiser, scan_interval: timedelta
+ ) -> None:
+ """Initialize HydrawiseDataUpdateCoordinator."""
+ super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
+ self.api = api
+
+ async def _async_update_data(self) -> None:
+ """Fetch the latest data from Hydrawise."""
+ result = await self.hass.async_add_executor_job(self.api.update_controller_info)
+ if not result:
+ raise UpdateFailed("Failed to refresh Hydrawise data")
diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py
new file mode 100644
index 000000000000..98b66069913b
--- /dev/null
+++ b/homeassistant/components/hydrawise/entity.py
@@ -0,0 +1,33 @@
+"""Base classes for Hydrawise entities."""
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import HydrawiseDataUpdateCoordinator
+
+
+class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
+ """Entity class for Hydrawise devices."""
+
+ _attr_attribution = "Data provided by hydrawise.com"
+
+ def __init__(
+ self,
+ *,
+ data: dict[str, Any],
+ coordinator: HydrawiseDataUpdateCoordinator,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the Hydrawise entity."""
+ super().__init__(coordinator=coordinator)
+ self.data = data
+ self.entity_description = description
+ self._attr_name = f"{self.data['name']} {description.name}"
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Return the state attributes."""
+ return {"identifier": self.data.get("relay")}
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index 17b27b059419..2489317a6a23 100644
--- a/homeassistant/components/hydrawise/manifest.json
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "hydrawise",
"name": "Hunter Hydrawise",
- "codeowners": ["@ptcryan"],
+ "codeowners": ["@dknowles2", "@ptcryan"],
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["hydrawiser"],
diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py
index 3114a50673fa..d1334143375d 100644
--- a/homeassistant/components/hydrawise/sensor.py
+++ b/homeassistant/components/hydrawise/sensor.py
@@ -1,8 +1,7 @@
"""Support for Hydrawise sprinkler sensors."""
from __future__ import annotations
-import logging
-
+from hydrawiser.core import Hydrawiser
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -12,15 +11,15 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
-from . import DATA_HYDRAWISE, HydrawiseEntity
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN, LOGGER
+from .coordinator import HydrawiseDataUpdateCoordinator
+from .entity import HydrawiseEntity
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -57,11 +56,12 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up a sensor for a Hydrawise device."""
- hydrawise = hass.data[DATA_HYDRAWISE].data
+ coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN]
+ hydrawise: Hydrawiser = coordinator.api
monitored_conditions = config[CONF_MONITORED_CONDITIONS]
entities = [
- HydrawiseSensor(zone, description)
+ HydrawiseSensor(data=zone, coordinator=coordinator, description=description)
for zone in hydrawise.relays
for description in SENSOR_TYPES
if description.key in monitored_conditions
@@ -73,11 +73,11 @@ def setup_platform(
class HydrawiseSensor(HydrawiseEntity, SensorEntity):
"""A sensor implementation for Hydrawise device."""
- def update(self) -> None:
+ @callback
+ def _handle_coordinator_update(self) -> None:
"""Get the latest data and updates the states."""
- mydata = self.hass.data[DATA_HYDRAWISE].data
- _LOGGER.debug("Updating Hydrawise sensor: %s", self.name)
- relay_data = mydata.relays[self.data["relay"] - 1]
+ LOGGER.debug("Updating Hydrawise sensor: %s", self.name)
+ relay_data = self.coordinator.api.relays[self.data["relay"] - 1]
if self.entity_description.key == "watering_time":
if relay_data["timestr"] == "Now":
self._attr_native_value = int(relay_data["run"] / 60)
@@ -85,7 +85,8 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
self._attr_native_value = 0
else: # _sensor_type == 'next_cycle'
next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS)
- _LOGGER.debug("New cycle time: %s", next_cycle)
- self._attr_native_value = dt.utc_from_timestamp(
- dt.as_timestamp(dt.now()) + next_cycle
+ LOGGER.debug("New cycle time: %s", next_cycle)
+ self._attr_native_value = dt_util.utc_from_timestamp(
+ dt_util.as_timestamp(dt_util.now()) + next_cycle
)
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
index cc12638f3295..00089bb8774f 100644
--- a/homeassistant/components/hydrawise/switch.py
+++ b/homeassistant/components/hydrawise/switch.py
@@ -1,9 +1,9 @@
"""Support for Hydrawise cloud switches."""
from __future__ import annotations
-import logging
from typing import Any
+from hydrawiser.core import Hydrawiser
import voluptuous as vol
from homeassistant.components.switch import (
@@ -13,20 +13,20 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.const import CONF_MONITORED_CONDITIONS
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import (
+from .const import (
ALLOWED_WATERING_TIME,
CONF_WATERING_TIME,
- DATA_HYDRAWISE,
DEFAULT_WATERING_TIME,
- HydrawiseEntity,
+ DOMAIN,
+ LOGGER,
)
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import HydrawiseDataUpdateCoordinator
+from .entity import HydrawiseEntity
SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
SwitchEntityDescription(
@@ -62,12 +62,18 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up a sensor for a Hydrawise device."""
- hydrawise = hass.data[DATA_HYDRAWISE].data
- monitored_conditions = config[CONF_MONITORED_CONDITIONS]
- default_watering_timer = config[CONF_WATERING_TIME]
+ coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN]
+ hydrawise: Hydrawiser = coordinator.api
+ monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS]
+ default_watering_timer: int = config[CONF_WATERING_TIME]
entities = [
- HydrawiseSwitch(zone, description, default_watering_timer)
+ HydrawiseSwitch(
+ data=zone,
+ coordinator=coordinator,
+ description=description,
+ default_watering_timer=default_watering_timer,
+ )
for zone in hydrawise.relays
for description in SWITCH_TYPES
if description.key in monitored_conditions
@@ -80,38 +86,41 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):
"""A switch implementation for Hydrawise device."""
def __init__(
- self, data, description: SwitchEntityDescription, default_watering_timer
+ self,
+ *,
+ data: dict[str, Any],
+ coordinator: HydrawiseDataUpdateCoordinator,
+ description: SwitchEntityDescription,
+ default_watering_timer: int,
) -> None:
"""Initialize a switch for Hydrawise device."""
- super().__init__(data, description)
+ super().__init__(data=data, coordinator=coordinator, description=description)
self._default_watering_timer = default_watering_timer
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
relay_data = self.data["relay"] - 1
if self.entity_description.key == "manual_watering":
- self.hass.data[DATA_HYDRAWISE].data.run_zone(
- self._default_watering_timer, relay_data
- )
+ self.coordinator.api.run_zone(self._default_watering_timer, relay_data)
elif self.entity_description.key == "auto_watering":
- self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data)
+ self.coordinator.api.suspend_zone(0, relay_data)
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
relay_data = self.data["relay"] - 1
if self.entity_description.key == "manual_watering":
- self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data)
+ self.coordinator.api.run_zone(0, relay_data)
elif self.entity_description.key == "auto_watering":
- self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data)
+ self.coordinator.api.suspend_zone(365, relay_data)
- def update(self) -> None:
+ @callback
+ def _handle_coordinator_update(self) -> None:
"""Update device state."""
relay_data = self.data["relay"] - 1
- mydata = self.hass.data[DATA_HYDRAWISE].data
- _LOGGER.debug("Updating Hydrawise switch: %s", self.name)
+ LOGGER.debug("Updating Hydrawise switch: %s", self.name)
+ timestr = self.coordinator.api.relays[relay_data]["timestr"]
if self.entity_description.key == "manual_watering":
- self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now"
+ self._attr_is_on = timestr == "Now"
elif self.entity_description.key == "auto_watering":
- self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and (
- mydata.relays[relay_data]["timestr"] != "Now"
- )
+ self._attr_is_on = timestr not in {"", "Now"}
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py
index 225953035a2a..e0b381d2362d 100644
--- a/homeassistant/components/iaqualink/__init__.py
+++ b/homeassistant/components/iaqualink/__init__.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
+from datetime import datetime
from functools import wraps
import logging
from typing import Any, Concatenate, ParamSpec, TypeVar
@@ -139,7 +140,7 @@ async def async_setup_entry( # noqa: C901
await hass.config_entries.async_forward_entry_setups(entry, platforms)
- async def _async_systems_update(now):
+ async def _async_systems_update(_: datetime) -> None:
"""Refresh internal state for all systems."""
for system in systems:
prev = system.online
@@ -161,7 +162,9 @@ async def async_setup_entry( # noqa: C901
async_dispatcher_send(hass, DOMAIN)
- async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL)
+ entry.async_on_unload(
+ async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL)
+ )
return True
diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py
index c14d387ba618..b4775f9193b9 100644
--- a/homeassistant/components/ihc/manual_setup.py
+++ b/homeassistant/components/ihc/manual_setup.py
@@ -12,7 +12,6 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
CONF_USERNAME,
- UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
@@ -81,11 +80,7 @@ LIGHT_SCHEMA = DEVICE_SCHEMA.extend(
)
SENSOR_SCHEMA = DEVICE_SCHEMA.extend(
- {
- vol.Optional(
- CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS
- ): cv.string
- }
+ {vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string}
)
IHC_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py
index 17c40cfc8753..766be89f0d4a 100644
--- a/homeassistant/components/image_upload/__init__.py
+++ b/homeassistant/components/image_upload/__init__.py
@@ -17,7 +17,7 @@ from homeassistant.components.http.static import CACHE_HEADERS
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import collection
+from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
@@ -38,6 +38,8 @@ UPDATE_FIELDS = {
vol.Optional("name"): vol.All(str, vol.Length(min=1)),
}
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Image integration."""
diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py
index 468181be5f7c..04069d42d7d2 100644
--- a/homeassistant/components/imap/__init__.py
+++ b/homeassistant/components/imap/__init__.py
@@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator_class = ImapPollingDataUpdateCoordinator
coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = (
- coordinator_class(hass, imap_client)
+ coordinator_class(hass, imap_client, entry)
)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py
index 71b09048e6f1..6a737df74760 100644
--- a/homeassistant/components/imap/config_flow.py
+++ b/homeassistant/components/imap/config_flow.py
@@ -10,29 +10,46 @@ from aioimaplib import AioImapException
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
-from homeassistant.core import callback
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult
+from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
+ BooleanSelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
)
+from homeassistant.helpers.template import Template
from homeassistant.util.ssl import SSLCipherList
from .const import (
CONF_CHARSET,
+ CONF_CUSTOM_EVENT_DATA_TEMPLATE,
CONF_FOLDER,
+ CONF_MAX_MESSAGE_SIZE,
CONF_SEARCH,
CONF_SERVER,
CONF_SSL_CIPHER_LIST,
+ DEFAULT_MAX_MESSAGE_SIZE,
DEFAULT_PORT,
DOMAIN,
+ MAX_MESSAGE_SIZE_LIMIT,
)
from .coordinator import connect_to_server
from .errors import InvalidAuth, InvalidFolder
+BOOLEAN_SELECTOR = BooleanSelector()
CIPHER_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=list(SSLCipherList),
@@ -40,6 +57,9 @@ CIPHER_SELECTOR = SelectSelector(
translation_key=CONF_SSL_CIPHER_LIST,
)
)
+TEMPLATE_SELECTOR = TextSelector(
+ TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
+)
CONFIG_SCHEMA = vol.Schema(
{
@@ -55,7 +75,8 @@ CONFIG_SCHEMA = vol.Schema(
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
- ): CIPHER_SELECTOR
+ ): CIPHER_SELECTOR,
+ vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
@@ -65,8 +86,18 @@ OPTIONS_SCHEMA = vol.Schema(
}
)
+OPTIONS_SCHEMA_ADVANCED = {
+ vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
+ vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
+ cv.positive_int,
+ vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
+ ),
+}
-async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
+
+async def validate_input(
+ hass: HomeAssistant, user_input: dict[str, Any]
+) -> dict[str, str]:
"""Validate user input."""
errors = {}
@@ -94,6 +125,12 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
errors[CONF_CHARSET] = "invalid_charset"
else:
errors[CONF_SEARCH] = "invalid_search"
+ if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE):
+ try:
+ Template(template, hass=hass).ensure_valid()
+ except TemplateError:
+ errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template"
+
return errors
@@ -121,7 +158,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
)
title = user_input[CONF_NAME]
- if await validate_input(data):
+ if await validate_input(self.hass, data):
raise AbortFlow("cannot_connect")
return self.async_create_entry(title=title, data=data)
@@ -144,12 +181,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
)
- if not (errors := await validate_input(user_input)):
+ if not (errors := await validate_input(self.hass, user_input)):
title = user_input[CONF_USERNAME]
return self.async_create_entry(title=title, data=user_input)
- schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input)
+ schema = self.add_suggested_values_to_schema(schema, user_input)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
@@ -167,7 +204,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
assert self._reauth_entry
if user_input is not None:
user_input = {**self._reauth_entry.data, **user_input}
- if not (errors := await validate_input(user_input)):
+ if not (errors := await validate_input(self.hass, user_input)):
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=user_input
)
@@ -221,7 +258,7 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry):
errors = {"base": err.reason}
else:
entry_data.update(user_input)
- errors = await validate_input(entry_data)
+ errors = await validate_input(self.hass, entry_data)
if not errors:
self.hass.config_entries.async_update_entry(
self.config_entry, data=entry_data
@@ -233,6 +270,9 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry):
)
return self.async_create_entry(data={})
- schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data)
+ schema = OPTIONS_SCHEMA
+ if self.show_advanced_options:
+ schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
+ schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py
index a1ca586b48b1..2e36dd41e168 100644
--- a/homeassistant/components/imap/const.py
+++ b/homeassistant/components/imap/const.py
@@ -8,6 +8,12 @@ CONF_SERVER: Final = "server"
CONF_FOLDER: Final = "folder"
CONF_SEARCH: Final = "search"
CONF_CHARSET: Final = "charset"
+CONF_MAX_MESSAGE_SIZE = "max_message_size"
+CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template"
CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list"
DEFAULT_PORT: Final = 993
+
+DEFAULT_MAX_MESSAGE_SIZE = 2048
+
+MAX_MESSAGE_SIZE_LIMIT = 30000
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index 31d028c0519e..d41aaf8c4975 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -5,6 +5,8 @@ import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
import email
+from email.header import decode_header, make_header
+from email.utils import parseaddr, parsedate_to_datetime
import logging
from typing import Any
@@ -16,19 +18,33 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
+ CONF_VERIFY_SSL,
CONTENT_TYPE_TEXT_PLAIN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryError,
+ TemplateError,
+)
+from homeassistant.helpers.json import json_bytes
+from homeassistant.helpers.template import Template
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util.ssl import SSLCipherList, client_context
+from homeassistant.util.ssl import (
+ SSLCipherList,
+ client_context,
+ create_no_verify_ssl_context,
+)
from .const import (
CONF_CHARSET,
+ CONF_CUSTOM_EVENT_DATA_TEMPLATE,
CONF_FOLDER,
+ CONF_MAX_MESSAGE_SIZE,
CONF_SEARCH,
CONF_SERVER,
CONF_SSL_CIPHER_LIST,
+ DEFAULT_MAX_MESSAGE_SIZE,
DOMAIN,
)
from .errors import InvalidAuth, InvalidFolder
@@ -38,13 +54,16 @@ _LOGGER = logging.getLogger(__name__)
BACKOFF_TIME = 10
EVENT_IMAP = "imap_content"
+MAX_EVENT_DATA_BYTES = 32168
async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL:
"""Connect to imap server and return client."""
- ssl_context = client_context(
- ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT)
- )
+ ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT)
+ if data.get(CONF_VERIFY_SSL, True):
+ ssl_context = client_context(ssl_cipher_list=ssl_cipher_list)
+ else:
+ ssl_context = create_no_verify_ssl_context()
client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context)
await client.wait_hello_from_server()
@@ -72,9 +91,9 @@ class ImapMessage:
"""Get the email headers."""
header_base: dict[str, tuple[str,]] = {}
for key, value in self.email_message.items():
- header: tuple[str,] = (str(value),)
- if header_base.setdefault(key, header) != header:
- header_base[key] += header # type: ignore[assignment]
+ header_instances: tuple[str,] = (str(value),)
+ if header_base.setdefault(key, header_instances) != header_instances:
+ header_base[key] += header_instances # type: ignore[assignment]
return header_base
@property
@@ -84,23 +103,26 @@ class ImapMessage:
date_str: str | None
if (date_str := self.email_message["Date"]) is None:
return None
- # In some cases a timezone or comment is added in parenthesis after the date
- # We want to strip that part to avoid parsing errors
- return datetime.strptime(
- date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z"
- )
+ try:
+ mail_dt_tm = parsedate_to_datetime(date_str)
+ except ValueError:
+ _LOGGER.debug(
+ "Parsed date %s is not compliant with rfc2822#section-3.3", date_str
+ )
+ return None
+ return mail_dt_tm
@property
def sender(self) -> str:
"""Get the parsed message sender from the email."""
- return str(email.utils.parseaddr(self.email_message["From"])[1])
+ return str(parseaddr(self.email_message["From"])[1])
@property
def subject(self) -> str:
"""Decode the message subject."""
- decoded_header = email.header.decode_header(self.email_message["Subject"])
- header = email.header.make_header(decoded_header)
- return str(header)
+ decoded_header = decode_header(self.email_message["Subject"])
+ subject_header = make_header(decoded_header)
+ return str(subject_header)
@property
def text(self) -> str:
@@ -108,9 +130,9 @@ class ImapMessage:
Will look for text/plain or use text/html if not found.
"""
- message_text = None
- message_html = None
- message_untyped_text = None
+ message_text: str | None = None
+ message_html: str | None = None
+ message_untyped_text: str | None = None
for part in self.email_message.walk():
if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN:
@@ -134,23 +156,29 @@ class ImapMessage:
if message_untyped_text is not None:
return message_untyped_text
- return self.email_message.get_payload()
+ return str(self.email_message.get_payload())
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""
config_entry: ConfigEntry
+ custom_event_template: Template | None
def __init__(
self,
hass: HomeAssistant,
imap_client: IMAP4_SSL,
+ entry: ConfigEntry,
update_interval: timedelta | None,
) -> None:
"""Initiate imap client."""
self.imap_client = imap_client
self._last_message_id: str | None = None
+ self.custom_event_template = None
+ _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE)
+ if _custom_event_template is not None:
+ self.custom_event_template = Template(_custom_event_template, hass=hass)
super().__init__(
hass,
_LOGGER,
@@ -177,14 +205,51 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"search": self.config_entry.data[CONF_SEARCH],
"folder": self.config_entry.data[CONF_FOLDER],
"date": message.date,
- "text": message.text[:2048],
+ "text": message.text,
"sender": message.sender,
"subject": message.subject,
"headers": message.headers,
}
+ if self.custom_event_template is not None:
+ try:
+ data["custom"] = self.custom_event_template.async_render(
+ data, parse_result=True
+ )
+ _LOGGER.debug(
+ "imap custom template (%s) for msgid %s rendered to: %s",
+ self.custom_event_template,
+ last_message_id,
+ data["custom"],
+ )
+ except TemplateError as err:
+ data["custom"] = None
+ _LOGGER.error(
+ "Error rendering imap custom template (%s) for msgid %s "
+ "failed with message: %s",
+ self.custom_event_template,
+ last_message_id,
+ err,
+ )
+ data["text"] = message.text[
+ : self.config_entry.data.get(
+ CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE
+ )
+ ]
+ if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES:
+ _LOGGER.warning(
+ "Custom imap_content event skipped, size (%s) exceeds "
+ "the maximal event size (%s), sender: %s, subject: %s",
+ size,
+ MAX_EVENT_DATA_BYTES,
+ message.sender,
+ message.subject,
+ )
+ return
+
self.hass.bus.fire(EVENT_IMAP, data)
_LOGGER.debug(
- "Message processed, sender: %s, subject: %s",
+ "Message with id %s processed, sender: %s, subject: %s",
+ last_message_id,
message.sender,
message.subject,
)
@@ -233,7 +298,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
_LOGGER.debug("Error while cleaning up imap connection")
self.imap_client = None
- async def shutdown(self, *_) -> None:
+ async def shutdown(self, *_: Any) -> None:
"""Close resources."""
await self._cleanup(log_error=True)
@@ -241,9 +306,11 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
- def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None:
+ def __init__(
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ ) -> None:
"""Initiate imap client."""
- super().__init__(hass, imap_client, timedelta(seconds=10))
+ super().__init__(hass, imap_client, entry, timedelta(seconds=10))
async def _async_update_data(self) -> int | None:
"""Update the number of unread emails."""
@@ -272,9 +339,11 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
- def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None:
+ def __init__(
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ ) -> None:
"""Initiate imap client."""
- super().__init__(hass, imap_client, None)
+ super().__init__(hass, imap_client, entry, None)
self._push_wait_task: asyncio.Task[None] | None = None
async def _async_update_data(self) -> int | None:
@@ -338,7 +407,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
await self._cleanup()
await asyncio.sleep(BACKOFF_TIME)
- async def shutdown(self, *_) -> None:
+ async def shutdown(self, *_: Any) -> None:
"""Close resources."""
if self._push_wait_task:
self._push_wait_task.cancel()
diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json
index e50370dd9b1c..1e237f72b442 100644
--- a/homeassistant/components/imap/strings.json
+++ b/homeassistant/components/imap/strings.json
@@ -10,7 +10,8 @@
"charset": "Character set",
"folder": "Folder",
"search": "IMAP search",
- "ssl_cipher_list": "SSL cipher list (Advanced)"
+ "ssl_cipher_list": "SSL cipher list (Advanced)",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
"reauth_confirm": {
@@ -39,7 +40,9 @@
"init": {
"data": {
"folder": "[%key:component::imap::config::step::user::data::folder%]",
- "search": "[%key:component::imap::config::step::user::data::search%]"
+ "search": "[%key:component::imap::config::step::user::data::search%]",
+ "custom_event_data_template": "Template to create custom event data",
+ "max_message_size": "Max message size (2048 < size < 30000)"
}
}
},
@@ -49,7 +52,8 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_charset": "[%key:component::imap::config::error::invalid_charset%]",
"invalid_folder": "[%key:component::imap::config::error::invalid_folder%]",
- "invalid_search": "[%key:component::imap::config::error::invalid_search%]"
+ "invalid_search": "[%key:component::imap::config::error::invalid_search%]",
+ "invalid_template": "Invalid template"
}
},
"selector": {
diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py
index c51c0fdd67c4..769b2d794d01 100644
--- a/homeassistant/components/input_datetime/__init__.py
+++ b/homeassistant/components/input_datetime/__init__.py
@@ -272,7 +272,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
if self.state is not None:
return
- default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00")
+ default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00")
# Priority 2: Old state
if (old_state := await self.async_get_last_state()) is None:
diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py
index 3105214bad4a..1667f5fb779a 100644
--- a/homeassistant/components/insteon/__init__.py
+++ b/homeassistant/components/insteon/__init__.py
@@ -24,11 +24,10 @@ from .const import (
CONF_X10,
DOMAIN,
INSTEON_PLATFORMS,
- ON_OFF_EVENTS,
)
from .schemas import convert_yaml_to_config_flow
from .utils import (
- add_on_off_event_device,
+ add_insteon_events,
async_register_services,
get_device_platforms,
register_new_device_callback,
@@ -159,8 +158,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for address in devices:
device = devices[address]
platforms = get_device_platforms(device)
- if ON_OFF_EVENTS in platforms:
- add_on_off_event_device(hass, device)
+ add_insteon_events(hass, device)
+ if not platforms:
create_insteon_device(hass, device, entry.entry_id)
_LOGGER.debug("Insteon device count: %s", len(devices))
diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py
index 1357cbaed5b4..9d1ec352bedc 100644
--- a/homeassistant/components/insteon/binary_sensor.py
+++ b/homeassistant/components/insteon/binary_sensor.py
@@ -14,11 +14,11 @@ from pyinsteon.groups import (
)
from homeassistant.components.binary_sensor import (
- DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -54,13 +54,13 @@ async def async_setup_entry(
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
hass,
- BINARY_SENSOR_DOMAIN,
+ Platform.BINARY_SENSOR,
InsteonBinarySensorEntity,
async_add_entities,
discovery_info,
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{BINARY_SENSOR_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}"
async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities)
async_add_insteon_binary_sensor_entities()
diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py
index f88dadf12234..cf5f4ac2c0cb 100644
--- a/homeassistant/components/insteon/climate.py
+++ b/homeassistant/components/insteon/climate.py
@@ -9,7 +9,6 @@ from pyinsteon.constants import ThermostatMode
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
- DOMAIN as CLIMATE_DOMAIN,
FAN_AUTO,
ClimateEntity,
ClimateEntityFeature,
@@ -17,7 +16,7 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -64,13 +63,13 @@ async def async_setup_entry(
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
hass,
- CLIMATE_DOMAIN,
+ Platform.CLIMATE,
InsteonClimateEntity,
async_add_entities,
discovery_info,
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{CLIMATE_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}"
async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities)
async_add_insteon_climate_entities()
diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py
index 60da74fdf019..15ce7c849e61 100644
--- a/homeassistant/components/insteon/config_flow.py
+++ b/homeassistant/components/insteon/config_flow.py
@@ -3,8 +3,7 @@ from __future__ import annotations
import logging
-from pyinsteon import async_connect
-import voluptuous as vol
+from pyinsteon import async_close, async_connect, devices
from homeassistant import config_entries
from homeassistant.components import dhcp, usb
@@ -44,6 +43,7 @@ from .schemas import (
build_remove_x10_schema,
build_x10_schema,
)
+from .utils import async_get_usb_ports
STEP_PLM = "plm"
STEP_HUB_V1 = "hubv1"
@@ -55,18 +55,10 @@ STEP_ADD_OVERRIDE = "add_override"
STEP_REMOVE_OVERRIDE = "remove_override"
STEP_REMOVE_X10 = "remove_x10"
MODEM_TYPE = "modem_type"
-PLM = "PowerLinc Modem (PLM)"
-HUB1 = "Hub version 1 (pre-2014)"
-HUB2 = "Hub version 2"
_LOGGER = logging.getLogger(__name__)
-def _only_one_selected(*args):
- """Test if only one item is True."""
- return sum(args) == 1
-
-
async def _async_connect(**kwargs):
"""Connect to the Insteon modem."""
try:
@@ -128,22 +120,10 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
"""Init the config flow."""
- errors = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
- if user_input is not None:
- selection = user_input.get(MODEM_TYPE)
-
- if selection == PLM:
- return await self.async_step_plm()
- if selection == HUB1:
- return await self.async_step_hubv1()
- return await self.async_step_hubv2()
- modem_types = [PLM, HUB1, HUB2]
- data_schema = vol.Schema({vol.Required(MODEM_TYPE): vol.In(modem_types)})
- return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors
- )
+ modem_types = [STEP_PLM, STEP_HUB_V1, STEP_HUB_V2]
+ return self.async_show_menu(step_id="user", menu_options=modem_types)
async def async_step_plm(self, user_input=None):
"""Set up the PLM modem type."""
@@ -153,7 +133,8 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="", data=user_input)
errors["base"] = "cannot_connect"
schema_defaults = user_input if user_input is not None else {}
- data_schema = build_plm_schema(**schema_defaults)
+ ports = await async_get_usb_ports(self.hass)
+ data_schema = build_plm_schema(ports, **schema_defaults)
return self.async_show_form(
step_id=STEP_PLM, data_schema=data_schema, errors=errors
)
@@ -243,57 +224,24 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None) -> FlowResult:
"""Init the options config flow."""
- errors = {}
- if user_input is not None:
- change_hub_config = user_input.get(STEP_CHANGE_HUB_CONFIG, False)
- change_plm_config = user_input.get(STEP_CHANGE_PLM_CONFIG, False)
- device_override = user_input.get(STEP_ADD_OVERRIDE, False)
- x10_device = user_input.get(STEP_ADD_X10, False)
- remove_override = user_input.get(STEP_REMOVE_OVERRIDE, False)
- remove_x10 = user_input.get(STEP_REMOVE_X10, False)
- if _only_one_selected(
- change_hub_config,
- change_plm_config,
- device_override,
- x10_device,
- remove_override,
- remove_x10,
- ):
- if change_hub_config:
- return await self.async_step_change_hub_config()
- if change_plm_config:
- return await self.async_step_change_plm_config()
- if device_override:
- return await self.async_step_add_override()
- if x10_device:
- return await self.async_step_add_x10()
- if remove_override:
- return await self.async_step_remove_override()
- if remove_x10:
- return await self.async_step_remove_x10()
- errors["base"] = "select_single"
+ menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10]
- data_schema = {
- vol.Optional(STEP_ADD_OVERRIDE): bool,
- vol.Optional(STEP_ADD_X10): bool,
- }
if self.config_entry.data.get(CONF_HOST):
- data_schema[vol.Optional(STEP_CHANGE_HUB_CONFIG)] = bool
+ menu_options.append(STEP_CHANGE_HUB_CONFIG)
else:
- data_schema[vol.Optional(STEP_CHANGE_PLM_CONFIG)] = bool
+ menu_options.append(STEP_CHANGE_PLM_CONFIG)
options = {**self.config_entry.options}
if options.get(CONF_OVERRIDE):
- data_schema[vol.Optional(STEP_REMOVE_OVERRIDE)] = bool
+ menu_options.append(STEP_REMOVE_OVERRIDE)
if options.get(CONF_X10):
- data_schema[vol.Optional(STEP_REMOVE_X10)] = bool
+ menu_options.append(STEP_REMOVE_X10)
- return self.async_show_form(
- step_id="init", data_schema=vol.Schema(data_schema), errors=errors
- )
+ return self.async_show_menu(step_id="init", menu_options=menu_options)
async def async_step_change_hub_config(self, user_input=None) -> FlowResult:
"""Change the Hub configuration."""
+ errors = {}
if user_input is not None:
data = {
**self.config_entry.data,
@@ -303,31 +251,41 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
if self.config_entry.data[CONF_HUB_VERSION] == 2:
data[CONF_USERNAME] = user_input[CONF_USERNAME]
data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
- self.hass.config_entries.async_update_entry(self.config_entry, data=data)
- return self.async_create_entry(
- title="",
- data={**self.config_entry.options},
- )
+ if devices.modem:
+ await async_close()
+
+ if await _async_connect(**data):
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, data=data
+ )
+ return self.async_create_entry(data={**self.config_entry.options})
+ errors["base"] = "cannot_connect"
data_schema = build_hub_schema(**self.config_entry.data)
return self.async_show_form(
- step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema
+ step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors
)
async def async_step_change_plm_config(self, user_input=None) -> FlowResult:
"""Change the PLM configuration."""
+ errors = {}
if user_input is not None:
data = {
**self.config_entry.data,
CONF_DEVICE: user_input[CONF_DEVICE],
}
- self.hass.config_entries.async_update_entry(self.config_entry, data=data)
- return self.async_create_entry(
- title="",
- data={**self.config_entry.options},
- )
- data_schema = build_plm_schema(**self.config_entry.data)
+ if devices.modem:
+ await async_close()
+ if await _async_connect(**data):
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, data=data
+ )
+ return self.async_create_entry(data={**self.config_entry.options})
+ errors["base"] = "cannot_connect"
+
+ ports = await async_get_usb_ports(self.hass)
+ data_schema = build_plm_schema(ports, **self.config_entry.data)
return self.async_show_form(
- step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema
+ step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors
)
async def async_step_add_override(self, user_input=None) -> FlowResult:
@@ -337,7 +295,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
try:
data = add_device_override({**self.config_entry.options}, user_input)
async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input)
- return self.async_create_entry(title="", data=data)
+ return self.async_create_entry(data=data)
except ValueError:
errors["base"] = "input_error"
schema_defaults = user_input if user_input is not None else {}
@@ -352,7 +310,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
options = add_x10_device({**self.config_entry.options}, user_input)
async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input)
- return self.async_create_entry(title="", data=options)
+ return self.async_create_entry(data=options)
schema_defaults: dict[str, str] = user_input if user_input is not None else {}
data_schema = build_x10_schema(**schema_defaults)
return self.async_show_form(
@@ -370,7 +328,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
SIGNAL_REMOVE_DEVICE_OVERRIDE,
user_input[CONF_ADDRESS],
)
- return self.async_create_entry(title="", data=options)
+ return self.async_create_entry(data=options)
data_schema = build_remove_override_schema(options[CONF_OVERRIDE])
return self.async_show_form(
@@ -386,7 +344,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow):
async_dispatcher_send(
self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode
)
- return self.async_create_entry(title="", data=options)
+ return self.async_create_entry(data=options)
data_schema = build_remove_x10_schema(options[CONF_X10])
return self.async_show_form(
diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py
index 5337ccd36c38..690401995898 100644
--- a/homeassistant/components/insteon/const.py
+++ b/homeassistant/components/insteon/const.py
@@ -130,7 +130,6 @@ EVENT_GROUP_OFF = "insteon.button_off"
EVENT_GROUP_ON_FAST = "insteon.button_on_fast"
EVENT_GROUP_OFF_FAST = "insteon.button_off_fast"
EVENT_CONF_BUTTON = "button"
-ON_OFF_EVENTS = "on_off_events"
STATE_NAME_LABEL_MAP = {
DIMMABLE_LIGHT_MAIN: "Main",
diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py
index 645450166b9f..69a66d304ced 100644
--- a/homeassistant/components/insteon/cover.py
+++ b/homeassistant/components/insteon/cover.py
@@ -4,11 +4,11 @@ from typing import Any
from homeassistant.components.cover import (
ATTR_POSITION,
- DOMAIN as COVER_DOMAIN,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -29,10 +29,10 @@ async def async_setup_entry(
def async_add_insteon_cover_entities(discovery_info=None):
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
- hass, COVER_DOMAIN, InsteonCoverEntity, async_add_entities, discovery_info
+ hass, Platform.COVER, InsteonCoverEntity, async_add_entities, discovery_info
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{COVER_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}"
async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities)
async_add_insteon_cover_entities()
diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py
index c7512ba0278b..b0d664a821b8 100644
--- a/homeassistant/components/insteon/fan.py
+++ b/homeassistant/components/insteon/fan.py
@@ -4,12 +4,9 @@ from __future__ import annotations
import math
from typing import Any
-from homeassistant.components.fan import (
- DOMAIN as FAN_DOMAIN,
- FanEntity,
- FanEntityFeature,
-)
+from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -36,10 +33,10 @@ async def async_setup_entry(
def async_add_insteon_fan_entities(discovery_info=None):
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
- hass, FAN_DOMAIN, InsteonFanEntity, async_add_entities, discovery_info
+ hass, Platform.FAN, InsteonFanEntity, async_add_entities, discovery_info
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{FAN_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}"
async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities)
async_add_insteon_fan_entities()
diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py
index fea1262bffdf..ee799e103f92 100644
--- a/homeassistant/components/insteon/ipdb.py
+++ b/homeassistant/components/insteon/ipdb.py
@@ -17,11 +17,6 @@ from pyinsteon.device_types.ipdb import (
DimmableLightingControl_SwitchLinc02,
DimmableLightingControl_ToggleLinc,
EnergyManagement_LoadController,
- GeneralController_ControlLinc,
- GeneralController_MiniRemote_4,
- GeneralController_MiniRemote_8,
- GeneralController_MiniRemote_Switch,
- GeneralController_RemoteLinc,
SecurityHealthSafety_DoorSensor,
SecurityHealthSafety_LeakSensor,
SecurityHealthSafety_MotionSensor,
@@ -47,76 +42,62 @@ from pyinsteon.device_types.ipdb import (
X10OnOffSensor,
)
-from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
-from homeassistant.components.climate import DOMAIN as CLIMATE
-from homeassistant.components.cover import DOMAIN as COVER
-from homeassistant.components.fan import DOMAIN as FAN
-from homeassistant.components.light import DOMAIN as LIGHT
-from homeassistant.components.lock import DOMAIN as LOCK
-from homeassistant.components.switch import DOMAIN as SWITCH
-
-from .const import ON_OFF_EVENTS
+from homeassistant.const import Platform
DEVICE_PLATFORM = {
- AccessControl_Morningstar: {LOCK: [1]},
- DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_Dial: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]},
- DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]},
+ AccessControl_Morningstar: {Platform.LOCK: [1]},
+ DimmableLightingControl: {Platform.LIGHT: [1]},
+ DimmableLightingControl_Dial: {Platform.LIGHT: [1]},
+ DimmableLightingControl_DinRail: {Platform.LIGHT: [1]},
+ DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]},
+ DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]},
+ DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]},
DimmableLightingControl_KeypadLinc_6: {
- LIGHT: [1],
- SWITCH: [3, 4, 5, 6],
- ON_OFF_EVENTS: [1, 3, 4, 5, 6],
+ Platform.LIGHT: [1],
+ Platform.SWITCH: [3, 4, 5, 6],
},
DimmableLightingControl_KeypadLinc_8: {
- LIGHT: [1],
- SWITCH: range(2, 9),
- ON_OFF_EVENTS: range(1, 9),
+ Platform.LIGHT: [1],
+ Platform.SWITCH: range(2, 9),
},
- DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_SwitchLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]},
- EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]},
- GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]},
- GeneralController_MiniRemote_4: {ON_OFF_EVENTS: range(1, 5)},
- GeneralController_MiniRemote_8: {ON_OFF_EVENTS: range(1, 9)},
- GeneralController_MiniRemote_Switch: {ON_OFF_EVENTS: [1, 2]},
- GeneralController_RemoteLinc: {ON_OFF_EVENTS: [1]},
- SecurityHealthSafety_DoorSensor: {BINARY_SENSOR: [1, 3, 4], ON_OFF_EVENTS: [1]},
- SecurityHealthSafety_LeakSensor: {BINARY_SENSOR: [2, 4]},
- SecurityHealthSafety_MotionSensor: {BINARY_SENSOR: [1, 2, 3], ON_OFF_EVENTS: [1]},
- SecurityHealthSafety_OpenCloseSensor: {BINARY_SENSOR: [1]},
- SecurityHealthSafety_Smokebridge: {BINARY_SENSOR: [1, 2, 3, 4, 6, 7]},
- SensorsActuators_IOLink: {SWITCH: [1], BINARY_SENSOR: [2], ON_OFF_EVENTS: [1, 2]},
- SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]},
- SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]},
+ DimmableLightingControl_LampLinc: {Platform.LIGHT: [1]},
+ DimmableLightingControl_OutletLinc: {Platform.LIGHT: [1]},
+ DimmableLightingControl_SwitchLinc01: {Platform.LIGHT: [1]},
+ DimmableLightingControl_SwitchLinc02: {Platform.LIGHT: [1]},
+ DimmableLightingControl_ToggleLinc: {Platform.LIGHT: [1]},
+ EnergyManagement_LoadController: {
+ Platform.SWITCH: [1],
+ Platform.BINARY_SENSOR: [2],
+ },
+ SecurityHealthSafety_DoorSensor: {Platform.BINARY_SENSOR: [1, 3, 4]},
+ SecurityHealthSafety_LeakSensor: {Platform.BINARY_SENSOR: [2, 4]},
+ SecurityHealthSafety_MotionSensor: {Platform.BINARY_SENSOR: [1, 2, 3]},
+ SecurityHealthSafety_OpenCloseSensor: {Platform.BINARY_SENSOR: [1]},
+ SecurityHealthSafety_Smokebridge: {Platform.BINARY_SENSOR: [1, 2, 3, 4, 6, 7]},
+ SensorsActuators_IOLink: {Platform.SWITCH: [1], Platform.BINARY_SENSOR: [2]},
+ SwitchedLightingControl: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_ApplianceLinc: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_DinRail: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_I3Outlet: {Platform.SWITCH: [1, 2]},
+ SwitchedLightingControl_InLineLinc01: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_InLineLinc02: {Platform.SWITCH: [1]},
SwitchedLightingControl_KeypadLinc_6: {
- SWITCH: [1, 3, 4, 5, 6],
- ON_OFF_EVENTS: [1, 3, 4, 5, 6],
+ Platform.SWITCH: [1, 3, 4, 5, 6],
},
SwitchedLightingControl_KeypadLinc_8: {
- SWITCH: range(1, 9),
- ON_OFF_EVENTS: range(1, 9),
+ Platform.SWITCH: range(1, 9),
},
- SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]},
- SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]},
- ClimateControl_Thermostat: {CLIMATE: [1]},
- ClimateControl_WirelessThermostat: {CLIMATE: [1]},
- WindowCovering: {COVER: [1]},
- X10Dimmable: {LIGHT: [1]},
- X10OnOff: {SWITCH: [1]},
- X10OnOffSensor: {BINARY_SENSOR: [1]},
+ SwitchedLightingControl_OnOffOutlet: {Platform.SWITCH: [1, 2]},
+ SwitchedLightingControl_OutletLinc: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_SwitchLinc01: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_SwitchLinc02: {Platform.SWITCH: [1]},
+ SwitchedLightingControl_ToggleLinc: {Platform.SWITCH: [1]},
+ ClimateControl_Thermostat: {Platform.CLIMATE: [1]},
+ ClimateControl_WirelessThermostat: {Platform.CLIMATE: [1]},
+ WindowCovering: {Platform.COVER: [1]},
+ X10Dimmable: {Platform.LIGHT: [1]},
+ X10OnOff: {Platform.SWITCH: [1]},
+ X10OnOffSensor: {Platform.BINARY_SENSOR: [1]},
}
diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py
index 579945e6f821..44574c696b4e 100644
--- a/homeassistant/components/insteon/light.py
+++ b/homeassistant/components/insteon/light.py
@@ -3,13 +3,9 @@ from typing import Any
from pyinsteon.config import ON_LEVEL
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS,
- DOMAIN as LIGHT_DOMAIN,
- ColorMode,
- LightEntity,
-)
+from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -32,10 +28,14 @@ async def async_setup_entry(
def async_add_insteon_light_entities(discovery_info=None):
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
- hass, LIGHT_DOMAIN, InsteonDimmerEntity, async_add_entities, discovery_info
+ hass,
+ Platform.LIGHT,
+ InsteonDimmerEntity,
+ async_add_entities,
+ discovery_info,
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{LIGHT_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}"
async_dispatcher_connect(hass, signal, async_add_insteon_light_entities)
async_add_insteon_light_entities()
diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py
index 17a7cf20111f..75487e7696cd 100644
--- a/homeassistant/components/insteon/lock.py
+++ b/homeassistant/components/insteon/lock.py
@@ -2,8 +2,9 @@
from typing import Any
-from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity
+from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -24,10 +25,10 @@ async def async_setup_entry(
def async_add_insteon_lock_entities(discovery_info=None):
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
- hass, LOCK_DOMAIN, InsteonLockEntity, async_add_entities, discovery_info
+ hass, Platform.LOCK, InsteonLockEntity, async_add_entities, discovery_info
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{LOCK_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}"
async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities)
async_add_insteon_lock_entities()
diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py
index 785aa90dd4a6..84b586e76494 100644
--- a/homeassistant/components/insteon/schemas.py
+++ b/homeassistant/components/insteon/schemas.py
@@ -285,9 +285,21 @@ def build_x10_schema(
)
-def build_plm_schema(device=vol.UNDEFINED):
+def _find_likely_port(ports):
+ """Return the most likely USB port for a PLM."""
+ test_strings = ["FTDI", "0403:6001", "10BF:"]
+ for port, name in ports.items():
+ for test_string in test_strings:
+ if test_string in name:
+ return port
+ return vol.UNDEFINED
+
+
+def build_plm_schema(ports: dict[str, str], device=vol.UNDEFINED):
"""Build the PLM schema for config flow."""
- return vol.Schema({vol.Required(CONF_DEVICE, default=device): str})
+ if not device or device == vol.UNDEFINED:
+ device = _find_likely_port(ports)
+ return vol.Schema({vol.Required(CONF_DEVICE, default=device): vol.In(ports)})
def build_hub_schema(
diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json
index b302165ce6f1..a93ba4a7476a 100644
--- a/homeassistant/components/insteon/strings.json
+++ b/homeassistant/components/insteon/strings.json
@@ -4,8 +4,10 @@
"step": {
"user": {
"description": "Select the Insteon modem type.",
- "data": {
- "modem_type": "Modem type."
+ "menu_options": {
+ "plm": "PowerLink Modem (PLM)",
+ "hubv1": "Hub version 1 (pre 2014)",
+ "hubv2": "Hub version 2"
}
},
"plm": {
@@ -38,8 +40,7 @@
}
},
"error": {
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "select_single": "Select one option."
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -50,7 +51,7 @@
"options": {
"step": {
"init": {
- "data": {
+ "menu_options": {
"change_hub_config": "Change the Hub configuration.",
"change_plm_config": "Change the PLM configuration.",
"add_override": "Add a device override.",
@@ -60,7 +61,7 @@
}
},
"change_hub_config": {
- "description": "Change the Insteon Hub connection information. You must restart Home Assistant after making this change. This does not change the configuration of the Hub itself. To change the configuration in the Hub use the Hub app.",
+ "description": "Change the Insteon Hub connection information.",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]",
@@ -69,7 +70,7 @@
}
},
"change_plm_config": {
- "description": "Change the Insteon PLM connection information. You must restart Home Assistant after making this change. This does not change the configuration of the PLM itself.",
+ "description": "Change the Insteon PLM connection information.",
"data": {
"device": "[%key:common::config_flow::data::usb_path%]"
}
@@ -106,7 +107,6 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "select_single": "Select one option.",
"input_error": "Invalid entries, please check your values."
}
}
diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py
index d9a15d383c06..8f7c396f2133 100644
--- a/homeassistant/components/insteon/switch.py
+++ b/homeassistant/components/insteon/switch.py
@@ -1,8 +1,9 @@
"""Support for INSTEON dimmers via PowerLinc Modem."""
from typing import Any
-from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
+from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -23,10 +24,14 @@ async def async_setup_entry(
def async_add_insteon_switch_entities(discovery_info=None):
"""Add the Insteon entities for the platform."""
async_add_insteon_entities(
- hass, SWITCH_DOMAIN, InsteonSwitchEntity, async_add_entities, discovery_info
+ hass,
+ Platform.SWITCH,
+ InsteonSwitchEntity,
+ async_add_entities,
+ discovery_info,
)
- signal = f"{SIGNAL_ADD_ENTITIES}_{SWITCH_DOMAIN}"
+ signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}"
async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities)
async_add_insteon_switch_entities()
diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py
index 0df823e49b1c..58b2430092cb 100644
--- a/homeassistant/components/insteon/utils.py
+++ b/homeassistant/components/insteon/utils.py
@@ -22,7 +22,9 @@ from pyinsteon.managers.x10_manager import (
async_x10_all_units_off,
)
from pyinsteon.x10_address import create as create_x10_address
+from serial.tools import list_ports
+from homeassistant.components import usb
from homeassistant.const import (
CONF_ADDRESS,
CONF_ENTITY_ID,
@@ -49,7 +51,6 @@ from .const import (
EVENT_GROUP_OFF_FAST,
EVENT_GROUP_ON,
EVENT_GROUP_ON_FAST,
- ON_OFF_EVENTS,
SIGNAL_ADD_DEFAULT_LINKS,
SIGNAL_ADD_DEVICE_OVERRIDE,
SIGNAL_ADD_ENTITIES,
@@ -102,8 +103,8 @@ def _register_event(event: Event, listener: Callable) -> None:
event.subscribe(listener, force_strong_ref=True)
-def add_on_off_event_device(hass: HomeAssistant, device: Device) -> None:
- """Register an Insteon device as an on/off event device."""
+def add_insteon_events(hass: HomeAssistant, device: Device) -> None:
+ """Register Insteon device events."""
@callback
def async_fire_group_on_off_event(
@@ -157,12 +158,8 @@ def register_new_device_callback(hass):
await device.async_status()
platforms = get_device_platforms(device)
for platform in platforms:
- if platform == ON_OFF_EVENTS:
- add_on_off_event_device(hass, device)
-
- else:
- signal = f"{SIGNAL_ADD_ENTITIES}_{platform}"
- dispatcher_send(hass, signal, {"address": device.address})
+ signal = f"{SIGNAL_ADD_ENTITIES}_{platform}"
+ dispatcher_send(hass, signal, {"address": device.address})
devices.subscribe(async_new_insteon_device, force_strong_ref=True)
@@ -398,3 +395,32 @@ def async_add_insteon_entities(
for group in groups:
new_entities.append(entity_type(device, group))
async_add_entities(new_entities)
+
+
+def get_usb_ports() -> dict[str, str]:
+ """Return a dict of USB ports and their friendly names."""
+ ports = list_ports.comports()
+ port_descriptions = {}
+ for port in ports:
+ vid: str | None = None
+ pid: str | None = None
+ if port.vid is not None and port.pid is not None:
+ usb_device = usb.usb_device_from_port(port)
+ vid = usb_device.vid
+ pid = usb_device.pid
+ dev_path = usb.get_serial_by_id(port.device)
+ human_name = usb.human_readable_device_name(
+ dev_path,
+ port.serial_number,
+ port.manufacturer,
+ port.description,
+ vid,
+ pid,
+ )
+ port_descriptions[dev_path] = human_name
+ return port_descriptions
+
+
+async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
+ """Return a dict of USB ports and their friendly names."""
+ return await hass.async_add_executor_job(get_usb_ports)
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index 64d83506ad95..b28b426d3af3 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -1,16 +1,19 @@
"""Numeric integration of data coming from a source sensor over time."""
from __future__ import annotations
-from decimal import Decimal, DecimalException
+from dataclasses import dataclass
+from decimal import Decimal, DecimalException, InvalidOperation
import logging
-from typing import Final
+from typing import Any, Final
+from typing_extensions import Self
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA,
+ RestoreSensor,
SensorDeviceClass,
- SensorEntity,
+ SensorExtraStoredData,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
@@ -28,7 +31,6 @@ from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
-from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -79,6 +81,53 @@ PLATFORM_SCHEMA = vol.All(
)
+@dataclass
+class IntegrationSensorExtraStoredData(SensorExtraStoredData):
+ """Object to hold extra stored data."""
+
+ source_entity: str | None
+ last_valid_state: Decimal | None
+
+ def as_dict(self) -> dict[str, Any]:
+ """Return a dict representation of the utility sensor data."""
+ data = super().as_dict()
+ data["source_entity"] = self.source_entity
+ data["last_valid_state"] = (
+ str(self.last_valid_state) if self.last_valid_state else None
+ )
+ return data
+
+ @classmethod
+ def from_dict(cls, restored: dict[str, Any]) -> Self | None:
+ """Initialize a stored sensor state from a dict."""
+ extra = SensorExtraStoredData.from_dict(restored)
+ if extra is None:
+ return None
+
+ source_entity = restored.get(ATTR_SOURCE_ID)
+
+ try:
+ last_valid_state = (
+ Decimal(str(restored.get("last_valid_state")))
+ if restored.get("last_valid_state")
+ else None
+ )
+ except InvalidOperation:
+ # last_period is corrupted
+ _LOGGER.error("Could not use last_valid_state")
+ return None
+
+ if last_valid_state is None:
+ return None
+
+ return cls(
+ extra.native_value,
+ extra.native_unit_of_measurement,
+ source_entity,
+ last_valid_state,
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -128,7 +177,8 @@ async def async_setup_platform(
async_add_entities([integral])
-class IntegrationSensor(RestoreEntity, SensorEntity):
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
+class IntegrationSensor(RestoreSensor):
"""Representation of an integration sensor."""
_attr_state_class = SensorStateClass.TOTAL
@@ -159,7 +209,8 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
self._unit_time = UNIT_TIME[unit_time]
self._unit_time_str = unit_time
self._attr_icon = "mdi:chart-histogram"
- self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
+ self._source_entity: str = source_entity
+ self._last_valid_state: Decimal | None = None
def _unit(self, source_unit: str) -> str:
"""Derive unit from the source sensor, SI prefix and time unit."""
@@ -174,10 +225,28 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
- if (state := await self.async_get_last_state()) is not None:
- if state.state == STATE_UNAVAILABLE:
- self._attr_available = False
- elif state.state != STATE_UNKNOWN:
+
+ if (last_sensor_data := await self.async_get_last_sensor_data()) is not None:
+ self._state = (
+ Decimal(str(last_sensor_data.native_value))
+ if last_sensor_data.native_value
+ else last_sensor_data.last_valid_state
+ )
+ self._attr_native_value = last_sensor_data.native_value
+ self._unit_of_measurement = last_sensor_data.native_unit_of_measurement
+ self._last_valid_state = last_sensor_data.last_valid_state
+
+ _LOGGER.debug(
+ "Restored state %s and last_valid_state %s",
+ self._state,
+ self._last_valid_state,
+ )
+ elif (state := await self.async_get_last_state()) is not None:
+ # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition)
+ if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
+ if state.state == STATE_UNAVAILABLE:
+ self._attr_available = False
+ else:
try:
self._state = Decimal(state.state)
except (DecimalException, ValueError) as err:
@@ -294,6 +363,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
self._state += integral
else:
self._state = integral
+ self._last_valid_state = self._state
self.async_write_ha_state()
self.async_on_remove(
@@ -313,3 +383,33 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
+
+ @property
+ def extra_state_attributes(self) -> dict[str, str] | None:
+ """Return the state attributes of the sensor."""
+ state_attr = {
+ ATTR_SOURCE_ID: self._source_entity,
+ }
+
+ return state_attr
+
+ @property
+ def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData:
+ """Return sensor specific state data to be restored."""
+ return IntegrationSensorExtraStoredData(
+ self.native_value,
+ self.native_unit_of_measurement,
+ self._source_entity,
+ self._last_valid_state,
+ )
+
+ async def async_get_last_sensor_data(
+ self,
+ ) -> IntegrationSensorExtraStoredData | None:
+ """Restore Utility Meter Sensor Extra Stored Data."""
+ if (restored_last_extra_data := await self.async_get_last_extra_data()) is None:
+ return None
+
+ return IntegrationSensorExtraStoredData.from_dict(
+ restored_last_extra_data.as_dict()
+ )
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index 2f5ea26a8a65..1f390d35370c 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -29,6 +29,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Intent component."""
diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json
index 1a25a26ee35f..5beaa1e318ca 100644
--- a/homeassistant/components/iotawatt/manifest.json
+++ b/homeassistant/components/iotawatt/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
"iot_class": "local_polling",
"loggers": ["iotawattpy"],
- "requirements": ["iotawattpy==0.1.0"]
+ "requirements": ["ha-iotawattpy==0.1.1"]
}
diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py
index 849a2055ce32..b616c7e4ae9b 100644
--- a/homeassistant/components/iotawatt/sensor.py
+++ b/homeassistant/components/iotawatt/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.helpers import device_registry as dr, entity, entity_registry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS
from .coordinator import IotawattUpdater
@@ -203,7 +203,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity):
return
if (begin := self._sensor_data.getBegin()) and (
- last_reset := dt.parse_datetime(begin)
+ last_reset := dt_util.parse_datetime(begin)
):
self._attr_last_reset = last_reset
diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py
index e39d1e1d864c..d3db0e766314 100644
--- a/homeassistant/components/iperf3/sensor.py
+++ b/homeassistant/components/iperf3/sensor.py
@@ -35,6 +35,7 @@ async def async_setup_platform(
async_add_entities(entities, True)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Iperf3Sensor(RestoreEntity, SensorEntity):
"""A Iperf3 sensor implementation."""
diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json
index 59f8c32c2107..e93f98327224 100644
--- a/homeassistant/components/ipp/manifest.json
+++ b/homeassistant/components/ipp/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["deepmerge", "pyipp"],
"quality_scale": "platinum",
- "requirements": ["pyipp==0.12.1"],
+ "requirements": ["pyipp==0.13.0"],
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
}
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index 2f60490d8c87..e6e23fdf8374 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -32,10 +32,8 @@ from .const import (
CONF_NETWORK,
CONF_SENSOR_STRING,
CONF_TLS_VER,
- CONF_VAR_SENSOR_STRING,
DEFAULT_IGNORE_STRING,
DEFAULT_SENSOR_STRING,
- DEFAULT_VAR_SENSOR_STRING,
DOMAIN,
ISY_CONF_FIRMWARE,
ISY_CONF_MODEL,
@@ -45,7 +43,7 @@ from .const import (
SCHEME_HTTP,
SCHEME_HTTPS,
)
-from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables
+from .helpers import _categorize_nodes, _categorize_programs
from .models import IsyData
from .services import async_setup_services, async_unload_services
from .util import _async_cleanup_registry_entries
@@ -75,9 +73,6 @@ async def async_setup_entry(
tls_version = isy_config.get(CONF_TLS_VER)
ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING)
sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING)
- variable_identifier = isy_options.get(
- CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING
- )
if host.scheme == SCHEME_HTTP:
https = False
@@ -132,9 +127,7 @@ async def async_setup_entry(
_categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(isy_data, isy.programs)
- # Categorize variables call to be removed with variable sensors in 2023.5.0
- _categorize_variables(isy_data, isy.variables, variable_identifier)
- # Gather ISY Variables to be added. Identifier used to enable by default.
+ # Gather ISY Variables to be added.
if isy.variables.children:
isy_data.devices[CONF_VARIABLES] = _create_service_device_info(
isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES
diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py
index 53ad87c9ddc5..611d04677104 100644
--- a/homeassistant/components/isy994/helpers.py
+++ b/homeassistant/components/isy994/helpers.py
@@ -22,7 +22,6 @@ from pyisy.constants import (
)
from pyisy.nodes import Group, Node, Nodes
from pyisy.programs import Programs
-from pyisy.variables import Variables
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform
from homeassistant.helpers.entity import DeviceInfo
@@ -349,8 +348,6 @@ def _categorize_nodes(
if getattr(node, "is_dimmable", False):
aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties)
for control in aux_controls:
- # Deprecated all aux properties as sensors. Update in 2023.5.0 to remove extras.
- isy_data.aux_properties[Platform.SENSOR].append((node, control))
platform = NODE_AUX_FILTERS[control]
isy_data.aux_properties[platform].append((node, control))
if hasattr(node, TAG_ENABLED):
@@ -432,20 +429,6 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None:
isy_data.programs[platform].append(entity)
-def _categorize_variables(
- isy_data: IsyData, variables: Variables, identifier: str
-) -> None:
- """Gather the ISY Variables to be added as sensors."""
- try:
- isy_data.variables[Platform.SENSOR] = [
- variables[vtype][vid]
- for (vtype, vname, vid) in variables.children
- if identifier in vname
- ]
- except KeyError as err:
- _LOGGER.error("Error adding ISY Variables: %s", err)
-
-
def convert_isy_value_to_hass(
value: int | float | None,
uom: str | None,
diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py
index efad6dbc5cf9..5f36fed6b6ac 100644
--- a/homeassistant/components/isy994/sensor.py
+++ b/homeassistant/components/isy994/sensor.py
@@ -22,7 +22,6 @@ from pyisy.constants import (
)
from pyisy.helpers import EventListener, NodeProperty
from pyisy.nodes import Node, NodeChangedEvent
-from pyisy.variables import Variable
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -44,7 +43,7 @@ from .const import (
UOM_ON_OFF,
UOM_TO_STATES,
)
-from .entity import ISYEntity, ISYNodeEntity
+from .entity import ISYNodeEntity
from .helpers import convert_isy_value_to_hass
# Disable general purpose and redundant sensors by default
@@ -100,8 +99,8 @@ ISY_CONTROL_TO_STATE_CLASS = {
control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS
}
ISY_CONTROL_TO_ENTITY_CATEGORY = {
- PROP_RAMP_RATE: EntityCategory.CONFIG,
- PROP_ON_LEVEL: EntityCategory.CONFIG,
+ PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC,
+ PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC,
PROP_COMMS_ERROR: EntityCategory.DIAGNOSTIC,
}
@@ -111,7 +110,7 @@ async def async_setup_entry(
) -> None:
"""Set up the ISY sensor platform."""
isy_data = hass.data[DOMAIN][entry.entry_id]
- entities: list[ISYSensorEntity | ISYSensorVariableEntity] = []
+ entities: list[ISYSensorEntity] = []
devices: dict[str, DeviceInfo] = isy_data.devices
for node in isy_data.nodes[Platform.SENSOR]:
@@ -134,9 +133,6 @@ async def async_setup_entry(
)
)
- for variable in isy_data.variables[Platform.SENSOR]:
- entities.append(ISYSensorVariableEntity(variable))
-
async_add_entities(entities)
@@ -292,35 +288,3 @@ class ISYAuxSensorEntity(ISYSensorEntity):
def available(self) -> bool:
"""Return entity availability."""
return cast(bool, self._node.enabled)
-
-
-class ISYSensorVariableEntity(ISYEntity, SensorEntity):
- """Representation of an ISY variable as a sensor device."""
-
- # Deprecated sensors, will be removed in 2023.5.0
- _attr_entity_registry_enabled_default = False
-
- def __init__(self, variable_node: Variable) -> None:
- """Initialize the ISY binary sensor program."""
- super().__init__(variable_node)
- self._name = variable_node.name
-
- @property
- def native_value(self) -> float | int | None:
- """Return the state of the variable."""
- return convert_isy_value_to_hass(self._node.status, "", self._node.prec)
-
- @property
- def extra_state_attributes(self) -> dict[str, Any]:
- """Get the state attributes for the device."""
- return {
- "init_value": convert_isy_value_to_hass(
- self._node.init, "", self._node.prec
- ),
- "last_edited": self._node.last_edited,
- }
-
- @property
- def icon(self) -> str:
- """Return the icon."""
- return "mdi:counter"
diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py
index 3d28e2bb0c02..e127d78229fa 100644
--- a/homeassistant/components/jewish_calendar/binary_sensor.py
+++ b/homeassistant/components/jewish_calendar/binary_sensor.py
@@ -116,6 +116,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity):
await super().async_added_to_hass()
self._schedule_update()
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ if self._update_unsub:
+ self._update_unsub()
+ self._update_unsub = None
+ return await super().async_will_remove_from_hass()
+
@callback
def _update(self, now: datetime | None = None) -> None:
"""Update the state of the sensor."""
diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py
new file mode 100644
index 000000000000..996d745a1d5b
--- /dev/null
+++ b/homeassistant/components/jvc_projector/__init__.py
@@ -0,0 +1,65 @@
+"""The jvc_projector integration."""
+
+from __future__ import annotations
+
+from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
+from homeassistant.core import Event, HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+
+from .const import DOMAIN
+from .coordinator import JvcProjectorDataUpdateCoordinator
+
+PLATFORMS = [Platform.REMOTE]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up integration from a config entry."""
+ device = JvcProjector(
+ host=entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ password=entry.data[CONF_PASSWORD],
+ )
+
+ try:
+ await device.connect(True)
+ except JvcProjectorConnectError as err:
+ await device.disconnect()
+ raise ConfigEntryNotReady(
+ f"Unable to connect to {entry.data[CONF_HOST]}"
+ ) from err
+ except JvcProjectorAuthError as err:
+ await device.disconnect()
+ raise ConfigEntryAuthFailed("Password authentication failed") from err
+
+ coordinator = JvcProjectorDataUpdateCoordinator(hass, device)
+ await coordinator.async_config_entry_first_refresh()
+
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+
+ async def disconnect(event: Event) -> None:
+ await device.disconnect()
+
+ entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ await hass.data[DOMAIN][entry.entry_id].device.disconnect()
+ hass.data[DOMAIN].pop(entry.entry_id)
+ return unload_ok
diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py
new file mode 100644
index 000000000000..181d11e1f562
--- /dev/null
+++ b/homeassistant/components/jvc_projector/config_flow.py
@@ -0,0 +1,129 @@
+"""Config flow for the jvc_projector integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
+from jvcprojector.projector import DEFAULT_PORT
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry, ConfigFlow
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.util.network import is_host_valid
+
+from .const import DOMAIN, NAME
+
+
+class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Config flow for the JVC Projector integration."""
+
+ VERSION = 1
+
+ _reauth_entry: ConfigEntry | None = None
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle user initiated device additions."""
+ errors = {}
+
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ port = user_input[CONF_PORT]
+ password = user_input.get(CONF_PASSWORD)
+
+ try:
+ if not is_host_valid(host):
+ raise InvalidHost
+
+ mac = await get_mac_address(host, port, password)
+ except InvalidHost:
+ errors["base"] = "invalid_host"
+ except JvcProjectorConnectError:
+ errors["base"] = "cannot_connect"
+ except JvcProjectorAuthError:
+ errors["base"] = "invalid_auth"
+ else:
+ await self.async_set_unique_id(format_mac(mac))
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password}
+ )
+
+ return self.async_create_entry(
+ title=NAME,
+ data={
+ CONF_HOST: host,
+ CONF_PORT: port,
+ CONF_PASSWORD: password,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Optional(CONF_PASSWORD): str,
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
+ """Perform reauth on password authentication error."""
+ self._reauth_entry = self.hass.config_entries.async_get_entry(
+ self.context["entry_id"]
+ )
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> FlowResult:
+ """Dialog that informs the user that reauth is required."""
+ assert self._reauth_entry
+
+ errors = {}
+
+ if user_input is not None:
+ host = self._reauth_entry.data[CONF_HOST]
+ port = self._reauth_entry.data[CONF_PORT]
+ password = user_input[CONF_PASSWORD]
+
+ try:
+ await get_mac_address(host, port, password)
+ except JvcProjectorConnectError:
+ errors["base"] = "cannot_connect"
+ except JvcProjectorAuthError:
+ errors["base"] = "invalid_auth"
+ else:
+ self.hass.config_entries.async_update_entry(
+ self._reauth_entry,
+ data={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password},
+ )
+ await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
+ errors=errors,
+ )
+
+
+class InvalidHost(Exception):
+ """Error indicating invalid network host."""
+
+
+async def get_mac_address(host: str, port: int, password: str | None) -> str:
+ """Get device mac address for config flow."""
+ device = JvcProjector(host, port=port, password=password)
+ try:
+ await device.connect(True)
+ finally:
+ await device.disconnect()
+ return device.mac
diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py
new file mode 100644
index 000000000000..e15aa93bfa5d
--- /dev/null
+++ b/homeassistant/components/jvc_projector/const.py
@@ -0,0 +1,5 @@
+"""Constants for the jvc_projector integration."""
+
+NAME = "JVC Projector"
+DOMAIN = "jvc_projector"
+MANUFACTURER = "JVC"
diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py
new file mode 100644
index 000000000000..a63d68781b34
--- /dev/null
+++ b/homeassistant/components/jvc_projector/coordinator.py
@@ -0,0 +1,62 @@
+"""Data update coordinator for the jvc_projector integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from jvcprojector import (
+ JvcProjector,
+ JvcProjectorAuthError,
+ JvcProjectorConnectError,
+ const,
+)
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+INTERVAL_SLOW = timedelta(seconds=60)
+INTERVAL_FAST = timedelta(seconds=6)
+
+
+class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
+ """Data update coordinator for the JVC Projector integration."""
+
+ def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass=hass,
+ logger=_LOGGER,
+ name=NAME,
+ update_interval=INTERVAL_SLOW,
+ )
+
+ self.device = device
+ self.unique_id = format_mac(device.mac)
+
+ async def _async_update_data(self) -> dict[str, str]:
+ """Get the latest state data."""
+ try:
+ state = await self.device.get_state()
+ except JvcProjectorConnectError as err:
+ raise UpdateFailed(f"Unable to connect to {self.device.host}") from err
+ except JvcProjectorAuthError as err:
+ raise ConfigEntryAuthFailed("Password authentication failed") from err
+
+ old_interval = self.update_interval
+
+ if state[const.POWER] != const.STANDBY:
+ self.update_interval = INTERVAL_FAST
+ else:
+ self.update_interval = INTERVAL_SLOW
+
+ if self.update_interval != old_interval:
+ _LOGGER.debug("Changed update interval to %s", self.update_interval)
+
+ return state
diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py
new file mode 100644
index 000000000000..5d1821c6b56f
--- /dev/null
+++ b/homeassistant/components/jvc_projector/entity.py
@@ -0,0 +1,38 @@
+"""Base Entity for the jvc_projector integration."""
+
+from __future__ import annotations
+
+import logging
+
+from jvcprojector import JvcProjector
+
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER, NAME
+from .coordinator import JvcProjectorDataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
+ """Defines a base JVC Projector entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self._attr_unique_id = coordinator.unique_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.unique_id)},
+ name=NAME,
+ model=self.device.model,
+ manufacturer=MANUFACTURER,
+ )
+
+ @property
+ def device(self) -> JvcProjector:
+ """Return the device representing the projector."""
+ return self.coordinator.device
diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json
new file mode 100644
index 000000000000..bc01da5d89a0
--- /dev/null
+++ b/homeassistant/components/jvc_projector/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "jvc_projector",
+ "name": "JVC Projector",
+ "codeowners": ["@SteveEasley"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/jvc_projector",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "loggers": ["jvcprojector"],
+ "requirements": ["pyjvcprojector==1.0.6"]
+}
diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py
new file mode 100644
index 000000000000..e33eef74c487
--- /dev/null
+++ b/homeassistant/components/jvc_projector/remote.py
@@ -0,0 +1,76 @@
+"""Remote platform for the jvc_projector integration."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+import logging
+from typing import Any
+
+from jvcprojector import const
+
+from homeassistant.components.remote import RemoteEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .entity import JvcProjectorEntity
+
+COMMANDS = {
+ "menu": const.REMOTE_MENU,
+ "up": const.REMOTE_UP,
+ "down": const.REMOTE_DOWN,
+ "left": const.REMOTE_LEFT,
+ "right": const.REMOTE_RIGHT,
+ "ok": const.REMOTE_OK,
+ "back": const.REMOTE_BACK,
+ "mpc": const.REMOTE_MPC,
+ "hide": const.REMOTE_HIDE,
+ "info": const.REMOTE_INFO,
+ "input": const.REMOTE_INPUT,
+ "cmd": const.REMOTE_CMD,
+ "advanced_menu": const.REMOTE_ADVANCED_MENU,
+ "picture_mode": const.REMOTE_PICTURE_MODE,
+ "color_profile": const.REMOTE_COLOR_PROFILE,
+ "lens_control": const.REMOTE_LENS_CONTROL,
+ "setting_memory": const.REMOTE_SETTING_MEMORY,
+ "gamma_settings": const.REMOTE_GAMMA_SETTINGS,
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the JVC Projector platform from a config entry."""
+ coordinator = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities([JvcProjectorRemote(coordinator)], True)
+
+
+class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
+ """Representation of a JVC Projector device."""
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self.coordinator.data["power"] in [const.ON, const.WARMING]
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self.device.power_on()
+ await self.coordinator.async_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self.device.power_off()
+ await self.coordinator.async_refresh()
+
+ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
+ """Send a remote command to the device."""
+ for cmd in command:
+ if cmd not in COMMANDS:
+ raise HomeAssistantError(f"{cmd} is not a known command")
+ _LOGGER.debug("Sending command '%s'", cmd)
+ await self.device.remote(COMMANDS[cmd])
diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json
new file mode 100644
index 000000000000..11e2f66f91ea
--- /dev/null
+++ b/homeassistant/components/jvc_projector/strings.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "IP address or hostname of projector",
+ "port": "IP port of projector (default is 20554)",
+ "password": "Optional password if projector is configured for one"
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "Password authentication failed",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "error": {
+ "invalid_host": "[%key:common::config_flow::error::invalid_host%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "Password authentication failed"
+ }
+ }
+}
diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py
index cdd80119bc5d..f4e7f9e04248 100644
--- a/homeassistant/components/keyboard/__init__.py
+++ b/homeassistant/components/keyboard/__init__.py
@@ -11,12 +11,15 @@ from homeassistant.const import (
SERVICE_VOLUME_UP,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "keyboard"
TAP_KEY_SCHEMA = vol.Schema({})
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Listen for keyboard events."""
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 3b7b96e90b6f..39143c8b84b7 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
@@ -27,6 +28,8 @@ DOMAIN = "kitchen_sink"
COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment."""
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index 91d1d9fa1c59..8a8e87b893fb 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -60,6 +60,7 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
+ CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
@@ -68,9 +69,11 @@ from .const import (
DOMAIN,
KNX_ADDRESS,
SUPPORTED_PLATFORMS,
+ TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure
+from .project import KNXProject
from .schema import (
BinarySensorSchema,
ButtonSchema,
@@ -91,6 +94,8 @@ from .schema import (
ga_validator,
sensor_type_validator,
)
+from .telegrams import Telegrams
+from .websocket import register_panel
_LOGGER = logging.getLogger(__name__)
@@ -222,6 +227,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
conf = dict(conf)
hass.data[DATA_KNX_CONFIG] = conf
+
return True
@@ -304,6 +310,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
)
+ await register_panel(hass)
+
return True
@@ -368,6 +376,8 @@ class KNXModule:
self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
self.entry = entry
+ self.project = KNXProject(hass=hass, entry=entry)
+
self.xknx = XKNX(
connection_config=self.connection_config(),
rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT],
@@ -376,6 +386,12 @@ class KNXModule:
self.xknx.connection_manager.register_connection_state_changed_cb(
self.connection_state_changed_cb
)
+ self.telegrams = Telegrams(
+ hass=hass,
+ xknx=self.xknx,
+ project=self.project,
+ log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT),
+ )
self.interface_device = KNXInterfaceDevice(
hass=hass, entry=entry, xknx=self.xknx
)
@@ -393,6 +409,7 @@ class KNXModule:
async def start(self) -> None:
"""Start XKNX object. Connect to tunneling or Routing device."""
+ await self.project.load_project()
await self.xknx.start()
async def stop(self, event: Event | None = None) -> None:
diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py
index 81610d62dcf7..0a405146d9cb 100644
--- a/homeassistant/components/knx/config_flow.py
+++ b/homeassistant/components/knx/config_flow.py
@@ -49,12 +49,15 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
+ CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
DEFAULT_ROUTING_IA,
DOMAIN,
+ TELEGRAM_LOG_DEFAULT,
+ TELEGRAM_LOG_MAX,
KNXConfigEntryData,
)
from .schema import ia_validator, ip_v4_validator
@@ -70,6 +73,7 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData(
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
route_back=False,
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
+ telegram_log_size=TELEGRAM_LOG_DEFAULT,
)
CONF_KEYRING_FILE: Final = "knxkeys_file"
@@ -203,7 +207,11 @@ class KNXCommonFlow(ABC, FlowHandler):
)
async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult:
- """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found."""
+ """Select a tunnel from a list.
+
+ Will be skipped if the gateway scan was unsuccessful
+ or if only one gateway was found.
+ """
if user_input is not None:
if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL:
if self._found_tunnels:
@@ -804,6 +812,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
self.new_entry_data = KNXConfigEntryData(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
+ telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE],
)
return self.finish_flow()
@@ -811,15 +820,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
vol.Required(
CONF_KNX_STATE_UPDATER,
default=self.initial_data.get(
- CONF_KNX_STATE_UPDATER,
- CONF_KNX_DEFAULT_STATE_UPDATER,
+ CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER
),
): selector.BooleanSelector(),
vol.Required(
CONF_KNX_RATE_LIMIT,
default=self.initial_data.get(
- CONF_KNX_RATE_LIMIT,
- CONF_KNX_DEFAULT_RATE_LIMIT,
+ CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT
),
): vol.All(
selector.NumberSelector(
@@ -831,9 +838,27 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
),
vol.Coerce(int),
),
+ vol.Required(
+ CONF_KNX_TELEGRAM_LOG_SIZE,
+ default=self.initial_data.get(
+ CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT
+ ),
+ ): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ min=0,
+ max=TELEGRAM_LOG_MAX,
+ mode=selector.NumberSelectorMode.BOX,
+ ),
+ ),
+ vol.Coerce(int),
+ ),
}
return self.async_show_form(
step_id="communication_settings",
data_schema=vol.Schema(data_schema),
last_step=True,
+ description_placeholders={
+ "telegram_log_size_max": f"{TELEGRAM_LOG_MAX}",
+ },
)
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index d006637abd17..5546a2d6fd9c 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -1,9 +1,12 @@
"""Constants for the KNX integration."""
from __future__ import annotations
+from collections.abc import Awaitable, Callable
from enum import Enum
from typing import Final, TypedDict
+from xknx.telegram import Telegram
+
from homeassistant.components.climate import (
PRESET_AWAY,
PRESET_COMFORT,
@@ -49,6 +52,10 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
+CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size"
+TELEGRAM_LOG_DEFAULT: Final = 50
+TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load
+
##
# Secure constants
##
@@ -76,29 +83,49 @@ DATA_HASS_CONFIG: Final = "knx_hass_config"
ATTR_COUNTER: Final = "counter"
ATTR_SOURCE: Final = "source"
+AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]]
+MessageCallbackType = Callable[[Telegram], None]
+
class KNXConfigEntryData(TypedDict, total=False):
"""Config entry for the KNX integration."""
connection_type: str
individual_address: str
- local_ip: str | None
+ local_ip: str | None # not required
multicast_group: str
multicast_port: int
- route_back: bool
+ route_back: bool # not required
+ host: str # only required for tunnelling
+ port: int # only required for tunnelling
+ tunnel_endpoint_ia: str | None
+ # KNX secure
+ user_id: int | None # not required
+ user_password: str | None # not required
+ device_authentication: str | None # not required
+ knxkeys_filename: str # not required
+ knxkeys_password: str # not required
+ backbone_key: str | None # not required
+ sync_latency_tolerance: int | None # not required
+ # OptionsFlow only
state_updater: bool
rate_limit: int
- host: str
- port: int
- tunnel_endpoint_ia: str | None
+ # Integration only (not forwarded to xknx)
+ telegram_log_size: int # not required
- user_id: int | None
- user_password: str | None
- device_authentication: str | None
- knxkeys_filename: str
- knxkeys_password: str
- backbone_key: str | None
- sync_latency_tolerance: int | None
+
+class KNXBusMonitorMessage(TypedDict):
+ """KNX bus monitor message."""
+
+ destination_address: str
+ destination_text: str | None
+ payload: str
+ type: str
+ value: str | None
+ source_address: str
+ source_text: str | None
+ direction: str
+ timestamp: str
class ColorTempModes(Enum):
diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py
new file mode 100644
index 000000000000..8a074b43b7dd
--- /dev/null
+++ b/homeassistant/components/knx/device_trigger.py
@@ -0,0 +1,103 @@
+"""Provides device triggers for KNX."""
+from __future__ import annotations
+
+from typing import Any, Final
+
+import voluptuous as vol
+
+from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
+from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.helpers import selector
+from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
+from homeassistant.helpers.typing import ConfigType
+
+from . import KNXModule
+from .const import DOMAIN
+from .project import KNXProject
+from .schema import ga_list_validator
+from .telegrams import TelegramDict
+
+TRIGGER_TELEGRAM: Final = "telegram"
+EXTRA_FIELD_DESTINATION: Final = "destination" # no translation support
+
+TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
+ {
+ vol.Optional(EXTRA_FIELD_DESTINATION): ga_list_validator,
+ vol.Required(CONF_TYPE): TRIGGER_TELEGRAM,
+ }
+)
+
+
+async def async_get_triggers(
+ hass: HomeAssistant, device_id: str
+) -> list[dict[str, Any]]:
+ """List device triggers for KNX devices."""
+ triggers = []
+
+ knx: KNXModule = hass.data[DOMAIN]
+ if knx.interface_device.device.id == device_id:
+ # Add trigger for KNX telegrams to interface device
+ triggers.append(
+ {
+ # Required fields of TRIGGER_BASE_SCHEMA
+ CONF_PLATFORM: "device",
+ CONF_DOMAIN: DOMAIN,
+ CONF_DEVICE_ID: device_id,
+ # Required fields of TRIGGER_SCHEMA
+ CONF_TYPE: TRIGGER_TELEGRAM,
+ }
+ )
+
+ return triggers
+
+
+async def async_get_trigger_capabilities(
+ hass: HomeAssistant, config: ConfigType
+) -> dict[str, vol.Schema]:
+ """List trigger capabilities."""
+ project: KNXProject = hass.data[DOMAIN].project
+ options = [
+ selector.SelectOptionDict(value=ga.address, label=f"{ga.address} - {ga.name}")
+ for ga in project.group_addresses.values()
+ ]
+ return {
+ "extra_fields": vol.Schema(
+ {
+ vol.Optional(EXTRA_FIELD_DESTINATION): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ multiple=True,
+ custom_value=True,
+ options=options,
+ ),
+ ),
+ }
+ )
+ }
+
+
+async def async_attach_trigger(
+ hass: HomeAssistant,
+ config: ConfigType,
+ action: TriggerActionType,
+ trigger_info: TriggerInfo,
+) -> CALLBACK_TYPE:
+ """Attach a trigger."""
+ dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, [])
+ job = HassJob(action, f"KNX device trigger {trigger_info}")
+ knx: KNXModule = hass.data[DOMAIN]
+
+ @callback
+ def async_call_trigger_action(telegram: TelegramDict) -> None:
+ """Filter Telegram and call trigger action."""
+ if dst_addresses and telegram["destination"] not in dst_addresses:
+ return
+ hass.async_run_hass_job(
+ job,
+ {"trigger": telegram},
+ )
+
+ return knx.telegrams.async_listen_telegram(
+ async_call_trigger_action, name="KNX device trigger call"
+ )
diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py
index 60a41c9a408e..2fada718d313 100644
--- a/homeassistant/components/knx/diagnostics.py
+++ b/homeassistant/components/knx/diagnostics.py
@@ -40,6 +40,11 @@ async def async_get_config_entry_diagnostics(
diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT)
+ if proj_info := knx_module.project.info:
+ diag["project_info"] = async_redact_data(proj_info, "name")
+ else:
+ diag["project_info"] = None
+
raw_config = await conf_util.async_hass_config_yaml(hass)
diag["configuration_yaml"] = raw_config.get(DOMAIN)
try:
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index d3aeced46c99..ba706c756cbd 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -1,13 +1,18 @@
{
"domain": "knx",
"name": "KNX",
+ "after_dependencies": ["panel_custom"],
"codeowners": ["@Julius2342", "@farmio", "@marvin-w"],
"config_flow": true,
- "dependencies": ["file_upload"],
+ "dependencies": ["file_upload", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/knx",
"integration_type": "hub",
"iot_class": "local_push",
- "loggers": ["xknx"],
+ "loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
- "requirements": ["xknx==2.9.0"]
+ "requirements": [
+ "xknx==2.10.0",
+ "xknxproject==3.1.0",
+ "knx_frontend==2023.5.31.141540"
+ ]
}
diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py
new file mode 100644
index 000000000000..274ef5cb9a3b
--- /dev/null
+++ b/homeassistant/components/knx/project.py
@@ -0,0 +1,117 @@
+"""Handle KNX project data."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+import logging
+from typing import Final
+
+from xknx.dpt import DPTBase
+from xknxproject import XKNXProj
+from xknxproject.models import (
+ Device,
+ GroupAddress as GroupAddressModel,
+ KNXProject as KNXProjectModel,
+ ProjectInfo,
+)
+
+from homeassistant.components.file_upload import process_uploaded_file
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.storage import Store
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STORAGE_VERSION: Final = 1
+STORAGE_KEY: Final = f"{DOMAIN}/knx_project.json"
+
+
+@dataclass
+class GroupAddressInfo:
+ """Group address info for runtime usage."""
+
+ address: str
+ name: str
+ description: str
+ dpt_main: int | None
+ dpt_sub: int | None
+ transcoder: type[DPTBase] | None
+
+
+def _create_group_address_info(ga_model: GroupAddressModel) -> GroupAddressInfo:
+ """Convert GroupAddress dict value into GroupAddressInfo instance."""
+ dpt = ga_model["dpt"]
+ transcoder = DPTBase.transcoder_by_dpt(dpt["main"], dpt.get("sub")) if dpt else None
+ return GroupAddressInfo(
+ address=ga_model["address"],
+ name=ga_model["name"],
+ description=ga_model["description"],
+ transcoder=transcoder,
+ dpt_main=dpt["main"] if dpt else None,
+ dpt_sub=dpt["sub"] if dpt else None,
+ )
+
+
+class KNXProject:
+ """Manage KNX project data."""
+
+ loaded: bool
+ devices: dict[str, Device]
+ group_addresses: dict[str, GroupAddressInfo]
+ info: ProjectInfo | None
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ ) -> None:
+ """Initialize project data."""
+ self.hass = hass
+ self._store = Store[KNXProjectModel](hass, STORAGE_VERSION, STORAGE_KEY)
+
+ self.initial_state()
+
+ def initial_state(self) -> None:
+ """Set initial state for project data."""
+ self.loaded = False
+ self.devices = {}
+ self.group_addresses = {}
+ self.info = None
+
+ async def load_project(self, data: KNXProjectModel | None = None) -> None:
+ """Load project data from storage."""
+ if project := data or await self._store.async_load():
+ self.devices = project["devices"]
+ self.info = project["info"]
+
+ for ga_model in project["group_addresses"].values():
+ ga_info = _create_group_address_info(ga_model)
+ self.group_addresses[ga_info.address] = ga_info
+
+ _LOGGER.debug(
+ "Loaded KNX project data with %s group addresses from storage",
+ len(self.group_addresses),
+ )
+ self.loaded = True
+
+ async def process_project_file(self, file_id: str, password: str) -> None:
+ """Process an uploaded project file."""
+
+ def _parse_project() -> KNXProjectModel:
+ with process_uploaded_file(self.hass, file_id) as file_path:
+ xknxproj = XKNXProj(
+ file_path,
+ password=password,
+ language=self.hass.config.language,
+ )
+ return xknxproj.parse()
+
+ project = await self.hass.async_add_executor_job(_parse_project)
+ await self._store.async_save(project)
+ await self.load_project(data=project)
+
+ async def remove_project_file(self) -> None:
+ """Remove project file from storage."""
+ await self._store.async_remove()
+ self.initial_state()
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index 0fce778c5218..cdd61379567d 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -133,11 +133,13 @@
"title": "Communication settings",
"data": {
"state_updater": "State updater",
- "rate_limit": "Rate limit"
+ "rate_limit": "Rate limit",
+ "telegram_log_size": "Telegram history limit"
},
"data_description": {
"state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.",
- "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40"
+ "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40",
+ "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
}
},
"connection_type": {
@@ -281,5 +283,10 @@
"name": "Telegrams"
}
}
+ },
+ "device_automation": {
+ "trigger_type": {
+ "telegram": "Telegram sent or received"
+ }
}
}
diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py
new file mode 100644
index 000000000000..5b429b0bdc18
--- /dev/null
+++ b/homeassistant/components/knx/telegrams.py
@@ -0,0 +1,122 @@
+"""KNX Telegram handler."""
+from __future__ import annotations
+
+from collections import deque
+from collections.abc import Callable
+import datetime as dt
+from typing import TypedDict
+
+from xknx import XKNX
+from xknx.exceptions import XKNXException
+from xknx.telegram import Telegram
+from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
+
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+import homeassistant.util.dt as dt_util
+
+from .project import KNXProject
+
+
+class TelegramDict(TypedDict):
+ """Represent a Telegram as a dict."""
+
+ destination: str
+ destination_name: str
+ direction: str
+ payload: int | tuple[int, ...] | None
+ source: str
+ source_name: str
+ telegramtype: str
+ timestamp: dt.datetime
+ unit: str | None
+ value: str | int | float | bool | None
+
+
+class Telegrams:
+ """Class to handle KNX telegrams."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ xknx: XKNX,
+ project: KNXProject,
+ log_size: int,
+ ) -> None:
+ """Initialize Telegrams class."""
+ self.hass = hass
+ self.project = project
+ self._jobs: list[HassJob[[TelegramDict], None]] = []
+ self._xknx_telegram_cb_handle = (
+ xknx.telegram_queue.register_telegram_received_cb(
+ telegram_received_cb=self._xknx_telegram_cb,
+ match_for_outgoing=True,
+ )
+ )
+ self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
+
+ async def _xknx_telegram_cb(self, telegram: Telegram) -> None:
+ """Handle incoming and outgoing telegrams from xknx."""
+ telegram_dict = self.telegram_to_dict(telegram)
+ self.recent_telegrams.appendleft(telegram_dict)
+ for job in self._jobs:
+ self.hass.async_run_hass_job(job, telegram_dict)
+
+ @callback
+ def async_listen_telegram(
+ self,
+ action: Callable[[TelegramDict], None],
+ name: str = "KNX telegram listener",
+ ) -> CALLBACK_TYPE:
+ """Register callback to listen for telegrams."""
+ job = HassJob(action, name=name)
+ self._jobs.append(job)
+
+ def remove_listener() -> None:
+ """Remove the listener."""
+ self._jobs.remove(job)
+
+ return remove_listener
+
+ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
+ """Convert a Telegram to a dict."""
+ dst_name = ""
+ payload_data: int | tuple[int, ...] | None = None
+ src_name = ""
+ transcoder = None
+ unit = None
+ value: str | int | float | bool | None = None
+
+ if (
+ ga_info := self.project.group_addresses.get(
+ f"{telegram.destination_address}"
+ )
+ ) is not None:
+ dst_name = ga_info.name
+ transcoder = ga_info.transcoder
+
+ if (
+ device := self.project.devices.get(f"{telegram.source_address}")
+ ) is not None:
+ src_name = f"{device['manufacturer_name']} {device['name']}"
+
+ if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)):
+ payload_data = telegram.payload.value.value
+ if transcoder is not None:
+ try:
+ value = transcoder.from_knx(telegram.payload.value)
+ unit = transcoder.unit
+ except XKNXException:
+ value = "Error decoding value"
+
+ return TelegramDict(
+ destination=f"{telegram.destination_address}",
+ destination_name=dst_name,
+ direction=telegram.direction.value,
+ payload=payload_data,
+ source=f"{telegram.source_address}",
+ source_name=src_name,
+ telegramtype=telegram.payload.__class__.__name__,
+ timestamp=dt_util.as_local(dt_util.utcnow()),
+ unit=unit,
+ value=value,
+ )
diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py
new file mode 100644
index 000000000000..d63ba89fbcc7
--- /dev/null
+++ b/homeassistant/components/knx/websocket.py
@@ -0,0 +1,219 @@
+"""KNX Websocket API."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from knx_frontend import get_build_id, locate_dir
+import voluptuous as vol
+from xknx.telegram import TelegramDirection
+from xknxproject.exceptions import XknxProjectException
+
+from homeassistant.components import panel_custom, websocket_api
+from homeassistant.core import HomeAssistant, callback
+
+from .const import DOMAIN, KNXBusMonitorMessage
+from .telegrams import TelegramDict
+
+if TYPE_CHECKING:
+ from . import KNXModule
+
+
+URL_BASE: Final = "/knx_static"
+
+
+async def register_panel(hass: HomeAssistant) -> None:
+ """Register the KNX Panel and Websocket API."""
+ websocket_api.async_register_command(hass, ws_info)
+ websocket_api.async_register_command(hass, ws_project_file_process)
+ websocket_api.async_register_command(hass, ws_project_file_remove)
+ websocket_api.async_register_command(hass, ws_group_monitor_info)
+ websocket_api.async_register_command(hass, ws_subscribe_telegram)
+
+ if DOMAIN not in hass.data.get("frontend_panels", {}):
+ path = locate_dir()
+ build_id = get_build_id()
+ hass.http.register_static_path(
+ URL_BASE, path, cache_headers=(build_id != "dev")
+ )
+ await panel_custom.async_register_panel(
+ hass=hass,
+ frontend_url_path=DOMAIN,
+ webcomponent_name="knx-frontend",
+ sidebar_title=DOMAIN.upper(),
+ sidebar_icon="mdi:bus-electric",
+ module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
+ embed_iframe=True,
+ require_admin=True,
+ )
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/info",
+ }
+)
+@callback
+def ws_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Handle get info command."""
+ knx: KNXModule = hass.data[DOMAIN]
+
+ _project_info = None
+ if project_info := knx.project.info:
+ _project_info = {
+ "name": project_info["name"],
+ "last_modified": project_info["last_modified"],
+ "tool_version": project_info["tool_version"],
+ }
+
+ connection.send_result(
+ msg["id"],
+ {
+ "version": knx.xknx.version,
+ "connected": knx.xknx.connection_manager.connected.is_set(),
+ "current_address": str(knx.xknx.current_address),
+ "project": _project_info,
+ },
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/project_file_process",
+ vol.Required("file_id"): str,
+ vol.Required("password"): str,
+ }
+)
+@websocket_api.async_response
+async def ws_project_file_process(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Handle get info command."""
+ knx: KNXModule = hass.data[DOMAIN]
+ try:
+ await knx.project.process_project_file(
+ file_id=msg["file_id"],
+ password=msg["password"],
+ )
+ except (ValueError, XknxProjectException) as err:
+ # ValueError could raise from file_upload integration
+ connection.send_error(
+ msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err)
+ )
+ return
+
+ connection.send_result(msg["id"])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/project_file_remove",
+ }
+)
+@websocket_api.async_response
+async def ws_project_file_remove(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Handle get info command."""
+ knx: KNXModule = hass.data[DOMAIN]
+ await knx.project.remove_project_file()
+ connection.send_result(msg["id"])
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/group_monitor_info",
+ }
+)
+@callback
+def ws_group_monitor_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Handle get info command of group monitor."""
+ knx: KNXModule = hass.data[DOMAIN]
+ recent_telegrams = [
+ _telegram_dict_to_group_monitor(telegram)
+ for telegram in knx.telegrams.recent_telegrams
+ ]
+ connection.send_result(
+ msg["id"],
+ {
+ "project_loaded": knx.project.loaded,
+ "recent_telegrams": recent_telegrams,
+ },
+ )
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "knx/subscribe_telegrams",
+ }
+)
+@callback
+def ws_subscribe_telegram(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict,
+) -> None:
+ """Subscribe to incoming and outgoing KNX telegrams."""
+ knx: KNXModule = hass.data[DOMAIN]
+
+ @callback
+ def forward_telegram(telegram: TelegramDict) -> None:
+ """Forward telegram to websocket subscription."""
+ connection.send_event(
+ msg["id"],
+ _telegram_dict_to_group_monitor(telegram),
+ )
+
+ connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram(
+ action=forward_telegram,
+ name="KNX GroupMonitor subscription",
+ )
+ connection.send_result(msg["id"])
+
+
+def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage:
+ """Convert a TelegramDict to a KNXBusMonitorMessage object."""
+ direction = (
+ "group_monitor_incoming"
+ if telegram["direction"] == TelegramDirection.INCOMING.value
+ else "group_monitor_outgoing"
+ )
+
+ _payload = telegram["payload"]
+ if isinstance(_payload, tuple):
+ payload = f"0x{bytes(_payload).hex()}"
+ elif isinstance(_payload, int):
+ payload = f"{_payload:d}"
+ else:
+ payload = ""
+
+ timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3]
+
+ if (value := telegram["value"]) is not None:
+ unit = telegram["unit"]
+ value = f"{value}{' ' + unit if unit else ''}"
+
+ return KNXBusMonitorMessage(
+ destination_address=telegram["destination"],
+ destination_text=telegram["destination_name"],
+ direction=direction,
+ payload=payload,
+ source_address=telegram["source"],
+ source_text=telegram["source_name"],
+ timestamp=timestamp,
+ type=telegram["telegramtype"],
+ value=value,
+ )
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index 3272491a06d3..86788db6ae60 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -26,6 +26,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
+ CONF_DEVICE_ID,
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
@@ -33,6 +34,7 @@ from homeassistant.const import (
CONF_PROXY_SSL,
CONF_SSL,
CONF_TIMEOUT,
+ CONF_TYPE,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STARTED,
)
@@ -279,6 +281,7 @@ class KodiEntity(MediaPlayerEntity):
self._connection = connection
self._kodi = kodi
self._unique_id = uid
+ self._device_id = None
self._players = None
self._properties = {}
self._item = {}
@@ -336,6 +339,20 @@ class KodiEntity(MediaPlayerEntity):
self._app_properties["muted"] = data["muted"]
self.async_write_ha_state()
+ @callback
+ def async_on_key_press(self, sender, data):
+ """Handle a incoming key press notification."""
+ self.hass.bus.async_fire(
+ f"{DOMAIN}_keypress",
+ {
+ CONF_TYPE: "keypress",
+ CONF_DEVICE_ID: self._device_id,
+ ATTR_ENTITY_ID: self.entity_id,
+ "sender": sender,
+ "data": data,
+ },
+ )
+
async def async_on_quit(self, sender, data):
"""Reset the player state on quit action."""
await self._clear_connection()
@@ -410,6 +427,7 @@ class KodiEntity(MediaPlayerEntity):
dev_reg = dr.async_get(self.hass)
device = dev_reg.async_get_device({(DOMAIN, self.unique_id)})
dev_reg.async_update_device(device.id, sw_version=sw_version)
+ self._device_id = device.id
self.async_schedule_update_ha_state(True)
@@ -457,6 +475,7 @@ class KodiEntity(MediaPlayerEntity):
self._connection.server.Application.OnVolumeChanged = (
self.async_on_volume_changed
)
+ self._connection.server.Other.OnKeyPress = self.async_on_key_press
self._connection.server.System.OnQuit = self.async_on_quit
self._connection.server.System.OnRestart = self.async_on_quit
self._connection.server.System.OnSleep = self.async_on_quit
diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py
index cbbaeefd85dc..ba8e762763dc 100644
--- a/homeassistant/components/kostal_plenticore/config_flow.py
+++ b/homeassistant/components/kostal_plenticore/config_flow.py
@@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+from .helper import get_hostname_id
_LOGGER = logging.getLogger(__name__)
@@ -32,9 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str:
session = async_get_clientsession(hass)
async with ApiClient(session, data["host"]) as client:
await client.login(data["password"])
- values = await client.get_setting_values("scb:network", "Hostname")
+ hostname_id = await get_hostname_id(client)
+ values = await client.get_setting_values("scb:network", hostname_id)
- return values["scb:network"]["Hostname"]
+ return values["scb:network"][hostname_id]
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py
index cb43486dbe01..35ec7bb94565 100644
--- a/homeassistant/components/kostal_plenticore/helper.py
+++ b/homeassistant/components/kostal_plenticore/helper.py
@@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientError
from pykoplenti import ApiClient, ApiException, AuthenticationException
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import HomeAssistant
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
@@ -23,6 +23,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_DataT = TypeVar("_DataT")
+_KNOWN_HOSTNAME_IDS = ("Network:Hostname", "Hostname")
class Plenticore:
@@ -69,6 +70,7 @@ class Plenticore:
)
# get some device meta data
+ hostname_id = await get_hostname_id(self._client)
settings = await self._client.get_setting_values(
{
"devices:local": [
@@ -78,7 +80,7 @@ class Plenticore:
"Properties:VersionIOC",
"Properties:VersionMC",
],
- "scb:network": ["Hostname"],
+ "scb:network": [hostname_id],
}
)
@@ -91,7 +93,7 @@ class Plenticore:
identifiers={(DOMAIN, device_local["Properties:SerialNo"])},
manufacturer="Kostal",
model=f"{prod1} {prod2}",
- name=settings["scb:network"]["Hostname"],
+ name=settings["scb:network"][hostname_id],
sw_version=f'IOC: {device_local["Properties:VersionIOC"]}'
+ f' MC: {device_local["Properties:VersionMC"]}',
)
@@ -171,7 +173,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]):
self._fetch: dict[str, list[str]] = defaultdict(list)
self._plenticore = plenticore
- def start_fetch_data(self, module_id: str, data_id: str) -> None:
+ def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE:
"""Start fetching the given data (module-id and data-id)."""
self._fetch[module_id].append(data_id)
@@ -180,7 +182,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]):
async def force_refresh(event_time: datetime) -> None:
await self.async_request_refresh()
- async_call_later(self.hass, 2, force_refresh)
+ return async_call_later(self.hass, 2, force_refresh)
def stop_fetch_data(self, module_id: str, data_id: str) -> None:
"""Stop fetching the given data (module-id and data-id)."""
@@ -251,7 +253,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]):
def start_fetch_data(
self, module_id: str, data_id: str, all_options: list[str]
- ) -> None:
+ ) -> CALLBACK_TYPE:
"""Start fetching the given data (module-id and entry-id)."""
self._fetch[module_id].append(data_id)
self._fetch[module_id].append(all_options)
@@ -261,7 +263,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]):
async def force_refresh(event_time: datetime) -> None:
await self.async_request_refresh()
- async_call_later(self.hass, 2, force_refresh)
+ return async_call_later(self.hass, 2, force_refresh)
def stop_fetch_data(
self, module_id: str, data_id: str, all_options: list[str]
@@ -403,3 +405,12 @@ class PlenticoreDataFormatter:
return state
return PlenticoreDataFormatter.EM_STATES.get(value)
+
+
+async def get_hostname_id(client: ApiClient) -> str:
+ """Check for known existing hostname ids."""
+ all_settings = await client.get_settings()
+ for entry in all_settings["scb:network"]:
+ if entry.id in _KNOWN_HOSTNAME_IDS:
+ return entry.id
+ raise ApiException("Hostname identifier not found in KNOWN_HOSTNAME_IDS")
diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py
index 6ea3526e360d..885b19faf28f 100644
--- a/homeassistant/components/kostal_plenticore/number.py
+++ b/homeassistant/components/kostal_plenticore/number.py
@@ -188,7 +188,9 @@ class PlenticoreDataNumber(
async def async_added_to_hass(self) -> None:
"""Register this entity on the Update Coordinator."""
await super().async_added_to_hass()
- self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ self.async_on_remove(
+ self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ )
async def async_will_remove_from_hass(self) -> None:
"""Unregister this entity from the Update Coordinator."""
diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py
index 6a7c0b35fdfa..2118d4b47c6c 100644
--- a/homeassistant/components/kostal_plenticore/select.py
+++ b/homeassistant/components/kostal_plenticore/select.py
@@ -127,7 +127,11 @@ class PlenticoreDataSelect(
async def async_added_to_hass(self) -> None:
"""Register this entity on the Update Coordinator."""
await super().async_added_to_hass()
- self.coordinator.start_fetch_data(self.module_id, self.data_id, self.options)
+ self.async_on_remove(
+ self.coordinator.start_fetch_data(
+ self.module_id, self.data_id, self.options
+ )
+ )
async def async_will_remove_from_hass(self) -> None:
"""Unregister this entity from the Update Coordinator."""
diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py
index a9b9433c1b67..036f2baf98e2 100644
--- a/homeassistant/components/kostal_plenticore/sensor.py
+++ b/homeassistant/components/kostal_plenticore/sensor.py
@@ -769,7 +769,9 @@ class PlenticoreDataSensor(
async def async_added_to_hass(self) -> None:
"""Register this entity on the Update Coordinator."""
await super().async_added_to_hass()
- self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ self.async_on_remove(
+ self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ )
async def async_will_remove_from_hass(self) -> None:
"""Unregister this entity from the Update Coordinator."""
diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py
index 9dc4740e6e90..4427f4bd4e1b 100644
--- a/homeassistant/components/kostal_plenticore/switch.py
+++ b/homeassistant/components/kostal_plenticore/switch.py
@@ -144,7 +144,9 @@ class PlenticoreDataSwitch(
async def async_added_to_hass(self) -> None:
"""Register this entity on the Update Coordinator."""
await super().async_added_to_hass()
- self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ self.async_on_remove(
+ self.coordinator.start_fetch_data(self.module_id, self.data_id)
+ )
async def async_will_remove_from_hass(self) -> None:
"""Unregister this entity from the Update Coordinator."""
diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py
index 46239485eb31..86793a94a4bd 100644
--- a/homeassistant/components/lacrosse_view/__init__.py
+++ b/homeassistant/components/lacrosse_view/__init__.py
@@ -1,6 +1,8 @@
"""The LaCrosse View integration."""
from __future__ import annotations
+import logging
+
from lacrosse_view import LaCrosse, LoginError
from homeassistant.config_entries import ConfigEntry
@@ -13,6 +15,7 @@ from .const import DOMAIN
from .coordinator import LaCrosseUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
+_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -22,17 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await api.login(entry.data["username"], entry.data["password"])
+ _LOGGER.debug("Log in successful")
except LoginError as error:
raise ConfigEntryAuthFailed from error
coordinator = LaCrosseUpdateCoordinator(hass, api, entry)
+ _LOGGER.debug("First refresh")
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"coordinator": coordinator,
}
+ _LOGGER.debug("Setting up platforms")
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py
index 2b694860bc81..67d294de1791 100644
--- a/homeassistant/components/lacrosse_view/config_flow.py
+++ b/homeassistant/components/lacrosse_view/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from lacrosse_view import LaCrosse, Location, LoginError
@@ -13,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN, LOGGER
+from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
@@ -21,6 +22,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required("password"): str,
}
)
+_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Location]:
@@ -29,14 +31,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca
api = LaCrosse(async_get_clientsession(hass))
try:
- await api.login(data["username"], data["password"])
+ if await api.login(data["username"], data["password"]):
+ _LOGGER.debug("Successfully logged in")
locations = await api.get_locations()
+ _LOGGER.debug(locations)
except LoginError as error:
raise InvalidAuth from error
if not locations:
- raise NoLocations("No locations found for account {}".format(data["username"]))
+ raise NoLocations(f'No locations found for account {data["username"]}')
return locations
@@ -57,6 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
+ _LOGGER.debug("Showing initial form")
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
@@ -66,11 +71,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(self.hass, user_input)
except InvalidAuth:
+ _LOGGER.exception("Could not login")
errors["base"] = "invalid_auth"
except NoLocations:
errors["base"] = "no_locations"
except Exception: # pylint: disable=broad-except
- LOGGER.exception("Unexpected exception")
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.data = user_input
@@ -83,8 +89,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
+
+ _LOGGER.debug("Moving on to location step")
return await self.async_step_location()
+ _LOGGER.debug("Showing errors")
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
@@ -95,6 +104,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle the location step."""
if not user_input:
+ _LOGGER.debug("Showing initial location selection")
return self.async_show_form(
step_id="location",
data_schema=vol.Schema(
@@ -113,7 +123,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
await self.async_set_unique_id(location_id)
-
self._abort_if_unique_id_configured()
return self.async_create_entry(
diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py
index cae11315bc7a..900463cff6e3 100644
--- a/homeassistant/components/lacrosse_view/const.py
+++ b/homeassistant/components/lacrosse_view/const.py
@@ -1,6 +1,4 @@
"""Constants for the LaCrosse View integration."""
-import logging
DOMAIN = "lacrosse_view"
-LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = 30
diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py
index 8dcbd8a2e5ea..b45fe3ae1b4e 100644
--- a/homeassistant/components/lacrosse_view/coordinator.py
+++ b/homeassistant/components/lacrosse_view/coordinator.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
+import logging
from time import time
from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor
@@ -11,7 +12,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import LOGGER, SCAN_INTERVAL
+from .const import SCAN_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
@@ -39,7 +42,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
self.id = entry.data["id"]
super().__init__(
hass,
- LOGGER,
+ _LOGGER,
name="LaCrosse View",
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
@@ -49,6 +52,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
now = int(time())
if self.last_update < now - 59 * 60: # Get new token once in a hour
+ _LOGGER.debug("Refreshing token")
self.last_update = now
try:
await self.api.login(self.username, self.password)
@@ -66,6 +70,8 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
except HTTPError as error:
raise ConfigEntryNotReady from error
+ _LOGGER.debug("Got data: %s", sensors)
+
# Verify that we have permission to read the sensors
for sensor in sensors:
if not sensor.permissions.get("read", False):
diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py
new file mode 100644
index 000000000000..754cc39d38ee
--- /dev/null
+++ b/homeassistant/components/lacrosse_view/diagnostics.py
@@ -0,0 +1,28 @@
+"""Diagnostics support for LaCrosse View."""
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics 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 LaCrosseUpdateCoordinator
+
+TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
+ "coordinator"
+ ]
+
+ return {
+ "entry": async_redact_data(entry.as_dict(), TO_REDACT),
+ "coordinator_data": coordinator.data,
+ }
diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json
index 51c15d4806e7..1236f63ddad8 100644
--- a/homeassistant/components/lacrosse_view/manifest.json
+++ b/homeassistant/components/lacrosse_view/manifest.json
@@ -5,5 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
"iot_class": "cloud_polling",
- "requirements": ["lacrosse-view==0.0.9"]
+ "loggers": ["lacrosse_view"],
+ "requirements": ["lacrosse-view==1.0.1"]
}
diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py
index 1c2daa2ba4ab..e001450fab05 100644
--- a/homeassistant/components/lacrosse_view/sensor.py
+++ b/homeassistant/components/lacrosse_view/sensor.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+import logging
from lacrosse_view import Sensor
@@ -28,7 +29,9 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
-from .const import DOMAIN, LOGGER
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
@dataclass
@@ -169,7 +172,7 @@ async def async_setup_entry(
f"title=LaCrosse%20View%20Unsupported%20sensor%20field:%20{field}"
)
- LOGGER.warning(message)
+ _LOGGER.warning(message)
continue
sensor_list.append(
LaCrosseViewSensor(
diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py
index 8e9da5851cf5..1dad190d7067 100644
--- a/homeassistant/components/lametric/config_flow.py
+++ b/homeassistant/components/lametric/config_flow.py
@@ -248,6 +248,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key}
)
+ notify_sound: Sound | None = None
+ if device.model != "sa5":
+ notify_sound = Sound(sound=NotificationSound.WIN)
+
await lametric.notify(
notification=Notification(
priority=NotificationPriority.CRITICAL,
@@ -255,7 +259,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
model=Model(
cycles=2,
frames=[Simple(text="Connected to Home Assistant!", icon=7956)],
- sound=Sound(sound=NotificationSound.WIN),
+ sound=notify_sound,
),
)
)
diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py
index 201283429317..fc26dd85ea32 100644
--- a/homeassistant/components/lastfm/__init__.py
+++ b/homeassistant/components/lastfm/__init__.py
@@ -1 +1,27 @@
"""The lastfm component."""
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .const import PLATFORMS
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up lastfm from a config entry."""
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload lastfm config entry."""
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py
new file mode 100644
index 000000000000..f7d7a9fd3144
--- /dev/null
+++ b/homeassistant/components/lastfm/config_flow.py
@@ -0,0 +1,223 @@
+"""Config flow for LastFm."""
+from __future__ import annotations
+
+from typing import Any
+
+from pylast import LastFMNetwork, User, WSError
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ OptionsFlowWithConfigEntry,
+)
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import callback
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+)
+from homeassistant.helpers.typing import ConfigType
+
+from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN
+
+PLACEHOLDERS = {"api_account_url": "https://www.last.fm/api/account/create"}
+
+CONFIG_SCHEMA: vol.Schema = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ vol.Required(CONF_MAIN_USER): str,
+ }
+)
+
+
+def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]:
+ """Get and validate lastFM User."""
+ user = LastFMNetwork(api_key=api_key).get_user(username)
+ errors = {}
+ try:
+ user.get_playcount()
+ except WSError as error:
+ if error.details == "User not found":
+ errors["base"] = "invalid_account"
+ elif (
+ error.details
+ == "Invalid API key - You must be granted a valid key by last.fm"
+ ):
+ errors["base"] = "invalid_auth"
+ else:
+ errors["base"] = "unknown"
+ except Exception: # pylint:disable=broad-except
+ errors["base"] = "unknown"
+ return user, errors
+
+
+def validate_lastfm_users(
+ api_key: str, usernames: list[str]
+) -> tuple[list[str], dict[str, str]]:
+ """Validate list of users. Return tuple of valid users and errors."""
+ valid_users = []
+ errors = {}
+ for username in usernames:
+ _, lastfm_errors = get_lastfm_user(api_key, username)
+ if lastfm_errors:
+ errors = lastfm_errors
+ else:
+ valid_users.append(username)
+ return valid_users, errors
+
+
+class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Config flow handler for LastFm."""
+
+ data: dict[str, Any] = {}
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> LastFmOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return LastFmOptionsFlowHandler(config_entry)
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Initialize user input."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self.data = user_input.copy()
+ _, errors = get_lastfm_user(
+ self.data[CONF_API_KEY], self.data[CONF_MAIN_USER]
+ )
+ if not errors:
+ return await self.async_step_friends()
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ description_placeholders=PLACEHOLDERS,
+ data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input),
+ )
+
+ async def async_step_friends(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Form to select other users and friends."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ users, errors = validate_lastfm_users(
+ self.data[CONF_API_KEY], user_input[CONF_USERS]
+ )
+ user_input[CONF_USERS] = users
+ if not errors:
+ return self.async_create_entry(
+ title="LastFM",
+ data={},
+ options={
+ CONF_API_KEY: self.data[CONF_API_KEY],
+ CONF_MAIN_USER: self.data[CONF_MAIN_USER],
+ CONF_USERS: [
+ self.data[CONF_MAIN_USER],
+ *user_input[CONF_USERS],
+ ],
+ },
+ )
+ try:
+ main_user, _ = get_lastfm_user(
+ self.data[CONF_API_KEY], self.data[CONF_MAIN_USER]
+ )
+ friends = [
+ SelectOptionDict(value=friend.name, label=friend.get_name(True))
+ for friend in main_user.get_friends()
+ ]
+ except WSError:
+ friends = []
+ return self.async_show_form(
+ step_id="friends",
+ errors=errors,
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_USERS): SelectSelector(
+ SelectSelectorConfig(
+ options=friends, custom_value=True, multiple=True
+ )
+ ),
+ }
+ ),
+ user_input or {CONF_USERS: []},
+ ),
+ )
+
+ async def async_step_import(self, import_config: ConfigType) -> FlowResult:
+ """Import config from yaml."""
+ for entry in self._async_current_entries():
+ if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]:
+ return self.async_abort(reason="already_configured")
+ users, _ = validate_lastfm_users(
+ import_config[CONF_API_KEY], import_config[CONF_USERS]
+ )
+ return self.async_create_entry(
+ title="LastFM",
+ data={},
+ options={
+ CONF_API_KEY: import_config[CONF_API_KEY],
+ CONF_MAIN_USER: None,
+ CONF_USERS: users,
+ },
+ )
+
+
+class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry):
+ """LastFm Options flow handler."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Initialize form."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ users, errors = validate_lastfm_users(
+ self.options[CONF_API_KEY], user_input[CONF_USERS]
+ )
+ user_input[CONF_USERS] = users
+ if not errors:
+ return self.async_create_entry(
+ title="LastFM",
+ data={
+ **self.options,
+ CONF_USERS: user_input[CONF_USERS],
+ },
+ )
+ if self.options[CONF_MAIN_USER]:
+ try:
+ main_user, _ = get_lastfm_user(
+ self.options[CONF_API_KEY],
+ self.options[CONF_MAIN_USER],
+ )
+ friends = [
+ SelectOptionDict(value=friend.name, label=friend.get_name(True))
+ for friend in main_user.get_friends()
+ ]
+ except WSError:
+ friends = []
+ else:
+ friends = []
+ return self.async_show_form(
+ step_id="init",
+ errors=errors,
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_USERS): SelectSelector(
+ SelectSelectorConfig(
+ options=friends, custom_value=True, multiple=True
+ )
+ ),
+ }
+ ),
+ user_input or self.options,
+ ),
+ )
diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py
new file mode 100644
index 000000000000..f895876c3c39
--- /dev/null
+++ b/homeassistant/components/lastfm/const.py
@@ -0,0 +1,19 @@
+"""Constants for LastFM."""
+import logging
+from typing import Final
+
+from homeassistant.const import Platform
+
+LOGGER = logging.getLogger(__package__)
+DOMAIN: Final = "lastfm"
+PLATFORMS = [Platform.SENSOR]
+DEFAULT_NAME = "LastFM"
+
+CONF_MAIN_USER = "main_user"
+CONF_USERS = "users"
+
+ATTR_LAST_PLAYED = "last_played"
+ATTR_PLAY_COUNT = "play_count"
+ATTR_TOP_PLAYED = "top_played"
+
+STATE_NOT_SCROBBLING = "Not Scrobbling"
diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json
index 392da95a2ac8..4315f4c53899 100644
--- a/homeassistant/components/lastfm/manifest.json
+++ b/homeassistant/components/lastfm/manifest.json
@@ -1,7 +1,8 @@
{
"domain": "lastfm",
"name": "Last.fm",
- "codeowners": [],
+ "codeowners": ["@joostlek"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lastfm",
"iot_class": "cloud_polling",
"loggers": ["pylast"],
diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py
index a25171f9c2ed..d8cf96be5ac6 100644
--- a/homeassistant/components/lastfm/sensor.py
+++ b/homeassistant/components/lastfm/sensor.py
@@ -2,27 +2,31 @@
from __future__ import annotations
import hashlib
-import logging
from pylast import LastFMNetwork, Track, User, WSError
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.device_registry import DeviceEntryType
+from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-LOGGER = logging.getLogger(__name__)
-
-CONF_USERS = "users"
-
-ATTR_LAST_PLAYED = "last_played"
-ATTR_PLAY_COUNT = "play_count"
-ATTR_TOP_PLAYED = "top_played"
-
-STATE_NOT_SCROBBLING = "Not Scrobbling"
+from .const import (
+ ATTR_LAST_PLAYED,
+ ATTR_PLAY_COUNT,
+ ATTR_TOP_PLAYED,
+ CONF_USERS,
+ DEFAULT_NAME,
+ DOMAIN,
+ LOGGER,
+ STATE_NOT_SCROBBLING,
+)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@@ -37,23 +41,46 @@ def format_track(track: Track) -> str:
return f"{track.artist} - {track.title}"
-def setup_platform(
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
- add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
- """Set up the Last.fm sensor platform."""
- lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY])
- entities = []
- for username in config[CONF_USERS]:
- try:
- user = lastfm_api.get_user(username)
- entities.append(LastFmSensor(user, lastfm_api))
- except WSError as exc:
- LOGGER.error("Failed to load LastFM user `%s`: %r", username, exc)
- return
- add_entities(entities, True)
+ """Set up the Last.fm sensor platform from yaml."""
+
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ )
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Initialize the entries."""
+
+ lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY])
+ async_add_entities(
+ (
+ LastFmSensor(lastfm_api.get_user(user), entry.entry_id)
+ for user in entry.options[CONF_USERS]
+ ),
+ True,
+ )
class LastFmSensor(SensorEntity):
@@ -62,14 +89,27 @@ class LastFmSensor(SensorEntity):
_attr_attribution = "Data provided by Last.fm"
_attr_icon = "mdi:radio-fm"
- def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None:
+ def __init__(self, user: User, entry_id: str) -> None:
"""Initialize the sensor."""
+ self._user = user
self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest()
self._attr_name = user.name
- self._user = user
+ self._attr_device_info = DeviceInfo(
+ configuration_url="https://www.last.fm",
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")},
+ manufacturer=DEFAULT_NAME,
+ name=f"{DEFAULT_NAME} {user.name}",
+ )
def update(self) -> None:
"""Update device state."""
+ try:
+ self._user.get_playcount()
+ except WSError as exc:
+ self._attr_available = False
+ LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc)
+ return
self._attr_entity_picture = self._user.get_image()
if now_playing := self._user.get_now_playing():
self._attr_native_value = format_track(now_playing)
diff --git a/homeassistant/components/lastfm/strings.json b/homeassistant/components/lastfm/strings.json
new file mode 100644
index 000000000000..f9156bed658c
--- /dev/null
+++ b/homeassistant/components/lastfm/strings.json
@@ -0,0 +1,45 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Request an API account at {api_account_url}.",
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]",
+ "main_user": "Last.fm username"
+ }
+ },
+ "friends": {
+ "description": "Fill in other users you want to add.",
+ "data": {
+ "users": "Last.fm usernames"
+ }
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_account": "Invalid username",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "Fill in other users you want to add.",
+ "data": {
+ "users": "Last.fm usernames"
+ }
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_account": "Invalid username",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml": {
+ "title": "The LastFM YAML configuration is being removed",
+ "description": "Configuring LastFM using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the LastFM YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
+ }
+ }
+}
diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py
index 34ee74413517..e85c9c81566f 100644
--- a/homeassistant/components/launch_library/__init__.py
+++ b/homeassistant/components/launch_library/__init__.py
@@ -40,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_update() -> LaunchLibraryData:
try:
return LaunchLibraryData(
- upcoming_launches=await launches.upcoming_launches(),
+ upcoming_launches=await launches.upcoming_launches(
+ filters={"limit": 1, "hide_recent_previous": "True"},
+ ),
starship_events=await launches.starship_events(),
)
except PyLaunchesException as ex:
diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json
index 3816027a0cce..778e5634b8c0 100644
--- a/homeassistant/components/launch_library/manifest.json
+++ b/homeassistant/components/launch_library/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/launch_library",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["pylaunches==1.3.0"]
+ "requirements": ["pylaunches==1.4.0"]
}
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index dcc65010e7d7..6153ecf45409 100644
--- a/homeassistant/components/lcn/manifest.json
+++ b/homeassistant/components/lcn/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
- "requirements": ["pypck==0.7.16"]
+ "requirements": ["pypck==0.7.17"]
}
diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py
index 204a5367e0b9..e127a4a98361 100644
--- a/homeassistant/components/ld2410_ble/__init__.py
+++ b/homeassistant/components/ld2410_ble/__init__.py
@@ -2,7 +2,7 @@
import logging
-from bleak_retry_connector import BleakError, get_device
+from bleak_retry_connector import BleakError, close_stale_connections, get_device
from ld2410_ble import LD2410BLE
from homeassistant.components import bluetooth
@@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(
f"Could not find LD2410B device with address {address}"
)
+
+ await close_stale_connections(ble_device)
+
ld2410_ble = LD2410BLE(ble_device)
coordinator = LD2410BLECoordinator(hass, ld2410_ble)
diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py
index 6ab255530940..2f0fd0797733 100644
--- a/homeassistant/components/ld2410_ble/coordinator.py
+++ b/homeassistant/components/ld2410_ble/coordinator.py
@@ -1,16 +1,22 @@
"""Data coordinator for receiving LD2410B updates."""
+from datetime import datetime
import logging
+import time
from ld2410_ble import LD2410BLE, LD2410BLEState
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+NEVER_TIME = -86400.0
+DEBOUNCE_SECONDS = 1.0
+
class LD2410BLECoordinator(DataUpdateCoordinator[None]):
"""Data coordinator for receiving LD2410B updates."""
@@ -26,15 +32,43 @@ class LD2410BLECoordinator(DataUpdateCoordinator[None]):
ld2410_ble.register_callback(self._async_handle_update)
ld2410_ble.register_disconnected_callback(self._async_handle_disconnect)
self.connected = False
+ self._last_update_time = NEVER_TIME
+ self._debounce_cancel: CALLBACK_TYPE | None = None
+ self._debounced_update_job = HassJob(
+ self._async_handle_debounced_update,
+ f"LD2410 {ld2410_ble.address} BLE debounced update",
+ )
+
+ @callback
+ def _async_handle_debounced_update(self, _now: datetime) -> None:
+ """Handle debounced update."""
+ self._debounce_cancel = None
+ self._last_update_time = time.monotonic()
+ self.async_set_updated_data(None)
@callback
def _async_handle_update(self, state: LD2410BLEState) -> None:
"""Just trigger the callbacks."""
self.connected = True
- self.async_set_updated_data(None)
+ previous_last_updated_time = self._last_update_time
+ self._last_update_time = time.monotonic()
+ if self._last_update_time - previous_last_updated_time >= DEBOUNCE_SECONDS:
+ self.async_set_updated_data(None)
+ return
+ if self._debounce_cancel is None:
+ self._debounce_cancel = async_call_later(
+ self.hass, DEBOUNCE_SECONDS, self._debounced_update_job
+ )
@callback
def _async_handle_disconnect(self) -> None:
"""Trigger the callbacks for disconnected."""
self.connected = False
self.async_update_listeners()
+
+ async def async_shutdown(self) -> None:
+ """Shutdown the coordinator."""
+ if self._debounce_cancel is not None:
+ self._debounce_cancel()
+ self._debounce_cancel = None
+ await super().async_shutdown()
diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py
index 2074966e1e7b..c7a5281bf618 100644
--- a/homeassistant/components/lg_netcast/media_player.py
+++ b/homeassistant/components/lg_netcast/media_player.py
@@ -74,6 +74,7 @@ def setup_platform(
class LgTVDevice(MediaPlayerEntity):
"""Representation of a LG TV."""
+ _attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_media_content_type = MediaType.CHANNEL
@@ -83,8 +84,6 @@ class LgTVDevice(MediaPlayerEntity):
self._name = name
self._muted = False
self._on_action_script = on_action_script
- # Assume that the TV is in Play mode
- self._playing = True
self._volume = 0
self._channel_id = None
self._channel_name = ""
@@ -106,7 +105,7 @@ class LgTVDevice(MediaPlayerEntity):
try:
with self._client as client:
- self._attr_state = MediaPlayerState.PLAYING
+ self._attr_state = MediaPlayerState.ON
self.__update_volume()
@@ -233,23 +232,12 @@ class LgTVDevice(MediaPlayerEntity):
"""Select input source."""
self._client.change_channel(self._sources[source])
- def media_play_pause(self) -> None:
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
def media_play(self) -> None:
"""Send play command."""
- self._playing = True
- self._attr_state = MediaPlayerState.PLAYING
self.send_command(LG_COMMAND.PLAY)
def media_pause(self) -> None:
"""Send media pause command to media player."""
- self._playing = False
- self._attr_state = MediaPlayerState.PAUSED
self.send_command(LG_COMMAND.PAUSE)
def media_next_track(self) -> None:
diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py
index f0c38cdfb111..41aa58fb9626 100644
--- a/homeassistant/components/lifx/__init__.py
+++ b/homeassistant/components/lifx/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
Platform,
)
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
@@ -125,7 +125,7 @@ class LIFXDiscoveryManager:
self.migrating,
)
self._cancel_discovery = async_track_time_interval(
- self.hass, self.async_discovery, discovery_interval
+ self.hass, self.async_discovery, discovery_interval, cancel_on_shutdown=True
)
async def async_discovery(self, *_: Any) -> None:
@@ -174,7 +174,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# to reduce the risk we miss devices because the event
# loop is blocked at startup.
discovery_manager.async_setup_discovery_interval()
- async_call_later(hass, DISCOVERY_COOLDOWN, _async_delayed_discovery)
+ async_call_later(
+ hass,
+ DISCOVERY_COOLDOWN,
+ HassJob(_async_delayed_discovery, cancel_on_shutdown=True),
+ )
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery
)
diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py
index 227d279f07ba..cb901dcbe477 100644
--- a/homeassistant/components/lifx/light.py
+++ b/homeassistant/components/lifx/light.py
@@ -170,9 +170,7 @@ class LIFXLight(LIFXEntity, LightEntity):
async def update_during_transition(self, when: int) -> None:
"""Update state at the start and end of a transition."""
- if self.postponed_update:
- self.postponed_update()
- self.postponed_update = None
+ self._cancel_postponed_update()
# Transition has started
self.async_write_ha_state()
@@ -327,6 +325,17 @@ class LIFXLight(LIFXEntity, LightEntity):
)
return await super().async_added_to_hass()
+ def _cancel_postponed_update(self) -> None:
+ """Cancel postponed update, if applicable."""
+ if self.postponed_update:
+ self.postponed_update()
+ self.postponed_update = None
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ self._cancel_postponed_update()
+ return await super().async_will_remove_from_hass()
+
class LIFXWhite(LIFXLight):
"""Representation of a white-only LIFX light."""
diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json
index 935e38d33d96..f89497b5ef95 100644
--- a/homeassistant/components/light/strings.json
+++ b/homeassistant/components/light/strings.json
@@ -86,5 +86,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py
index e774a27999de..9feefd6e24dd 100644
--- a/homeassistant/components/lightwave/__init__.py
+++ b/homeassistant/components/lightwave/__init__.py
@@ -1,4 +1,6 @@
"""Support for device connected via Lightwave WiFi-link hub."""
+import logging
+
from lightwave.lightwave import LWLink
import voluptuous as vol
@@ -20,12 +22,13 @@ CONF_PROXY_PORT = "proxy_port"
CONF_TRV = "trv"
CONF_TRVS = "trvs"
DEFAULT_PROXY_PORT = 7878
-DEFAULT_PROXY_IP = "127.0.0.1"
DOMAIN = "lightwave"
LIGHTWAVE_LINK = f"{DOMAIN}_link"
LIGHTWAVE_TRV_PROXY = f"{DOMAIN}_proxy"
LIGHTWAVE_TRV_PROXY_PORT = f"{DOMAIN}_proxy_port"
+_LOGGER = logging.getLogger(__name__)
+
CONFIG_SCHEMA = vol.Schema(
{
@@ -44,9 +47,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(
CONF_PROXY_PORT, default=DEFAULT_PROXY_PORT
): cv.port,
- vol.Optional(
- CONF_PROXY_IP, default=DEFAULT_PROXY_IP
- ): cv.string,
+ vol.Optional(CONF_PROXY_IP): cv.string,
vol.Required(CONF_TRVS, default={}): {
cv.string: vol.Schema(
{
@@ -84,9 +85,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if trv := config[DOMAIN][CONF_TRV]:
trvs = trv[CONF_TRVS]
- proxy_ip = trv[CONF_PROXY_IP]
+ proxy_ip = trv.get(CONF_PROXY_IP)
proxy_port = trv[CONF_PROXY_PORT]
- lwlink.set_trv_proxy(proxy_ip, proxy_port)
+ if proxy_ip is None:
+ await lwlink.LW_listen()
+ else:
+ lwlink.set_trv_proxy(proxy_ip, proxy_port)
+ _LOGGER.warning(
+ "Proxy no longer required, remove `proxy_ip` from config to use builtin listener"
+ )
for platform in PLATFORMS:
hass.async_create_task(
diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json
index 86c6a9263f3d..d242195a71c0 100644
--- a/homeassistant/components/lightwave/manifest.json
+++ b/homeassistant/components/lightwave/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/lightwave",
"iot_class": "assumed_state",
"loggers": ["lightwave"],
- "requirements": ["lightwave==0.20"]
+ "requirements": ["lightwave==0.24"]
}
diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py
index cf76213a88e2..181783b6bbdc 100644
--- a/homeassistant/components/lirc/__init__.py
+++ b/homeassistant/components/lirc/__init__.py
@@ -8,6 +8,7 @@ import lirc
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -20,6 +21,8 @@ EVENT_IR_COMMAND_RECEIVED = "ir_command_received"
ICON = "mdi:remote"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LIRC capability."""
diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index c68d99bfb22a..8cbce69dc7c1 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
STATE_UNLOCKED,
STATE_UNLOCKING,
)
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
@@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType, StateType
_LOGGER = logging.getLogger(__name__)
ATTR_CHANGED_BY = "changed_by"
+CONF_DEFAULT_CODE = "default_code"
DOMAIN = "lock"
SCAN_INTERVAL = timedelta(seconds=30)
@@ -88,7 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None:
"""Lock the lock."""
- code: str = service_call.data.get(ATTR_CODE, "")
+ code: str = service_call.data.get(
+ ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access
+ )
if entity.code_format_cmp and not entity.code_format_cmp.match(code):
raise ValueError(
f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}"
@@ -98,7 +101,9 @@ async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None:
async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None:
"""Unlock the lock."""
- code: str = service_call.data.get(ATTR_CODE, "")
+ code: str = service_call.data.get(
+ ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access
+ )
if entity.code_format_cmp and not entity.code_format_cmp.match(code):
raise ValueError(
f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}"
@@ -108,7 +113,9 @@ async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None:
async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None:
"""Open the door latch."""
- code: str = service_call.data.get(ATTR_CODE, "")
+ code: str = service_call.data.get(
+ ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access
+ )
if entity.code_format_cmp and not entity.code_format_cmp.match(code):
raise ValueError(
f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}"
@@ -145,6 +152,7 @@ class LockEntity(Entity):
_attr_is_jammed: bool | None = None
_attr_state: None = None
_attr_supported_features: LockEntityFeature = LockEntityFeature(0)
+ _lock_option_default_code: str = ""
__code_format_cmp: re.Pattern[str] | None = None
@property
@@ -243,3 +251,34 @@ class LockEntity(Entity):
def supported_features(self) -> LockEntityFeature:
"""Return the list of supported features."""
return self._attr_supported_features
+
+ async def async_internal_added_to_hass(self) -> None:
+ """Call when the sensor entity is added to hass."""
+ await super().async_internal_added_to_hass()
+ if not self.registry_entry:
+ return
+ self._async_read_entity_options()
+
+ @callback
+ def async_registry_entry_updated(self) -> None:
+ """Run when the entity registry entry has been updated."""
+ self._async_read_entity_options()
+
+ @callback
+ def _async_read_entity_options(self) -> None:
+ """Read entity options from entity registry.
+
+ Called when the entity registry entry has been updated and before the lock is
+ added to the state machine.
+ """
+ assert self.registry_entry
+ if (lock_options := self.registry_entry.options.get(DOMAIN)) and (
+ custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE)
+ ):
+ if self.code_format_cmp and self.code_format_cmp.match(
+ custom_default_lock_code
+ ):
+ self._lock_option_default_code = custom_default_lock_code
+ return
+
+ self._lock_option_default_code = ""
diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json
index da4b5217b862..b77bf5e6900a 100644
--- a/homeassistant/components/lock/strings.json
+++ b/homeassistant/components/lock/strings.json
@@ -34,5 +34,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py
index ee2ae3da4d95..0c614972e1e3 100644
--- a/homeassistant/components/logbook/__init__.py
+++ b/homeassistant/components/logbook/__init__.py
@@ -127,7 +127,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
possible_merged_entities_filter = convert_include_exclude_filter(merged_filter)
if not possible_merged_entities_filter.empty_filter:
filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter)
- entities_filter = possible_merged_entities_filter
+ entities_filter = possible_merged_entities_filter.get_filter()
else:
filters = None
entities_filter = None
diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py
index c8f55331de13..3a1ec971b54e 100644
--- a/homeassistant/components/logbook/helpers.py
+++ b/homeassistant/components/logbook/helpers.py
@@ -23,7 +23,6 @@ from homeassistant.core import (
split_entity_id,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.event import async_track_state_change_event
from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
@@ -104,7 +103,7 @@ def extract_attr(source: dict[str, Any], attr: str) -> list[str]:
@callback
def event_forwarder_filtered(
target: Callable[[Event], None],
- entities_filter: EntityFilter | None,
+ entities_filter: Callable[[str], bool] | None,
entity_ids: list[str] | None,
device_ids: list[str] | None,
) -> Callable[[Event], None]:
@@ -159,7 +158,7 @@ def async_subscribe_events(
subscriptions: list[CALLBACK_TYPE],
target: Callable[[Event], None],
event_types: tuple[str, ...],
- entities_filter: EntityFilter | None,
+ entities_filter: Callable[[str], bool] | None,
entity_ids: list[str] | None,
device_ids: list[str] | None,
) -> None:
diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py
index 86dcfdf82c51..e351ee6bb613 100644
--- a/homeassistant/components/logbook/models.py
+++ b/homeassistant/components/logbook/models.py
@@ -16,7 +16,6 @@ from homeassistant.components.recorder.models import (
)
from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED
from homeassistant.core import Context, Event, State, callback
-from homeassistant.helpers.entityfilter import EntityFilter
import homeassistant.util.dt as dt_util
from homeassistant.util.json import json_loads
from homeassistant.util.ulid import ulid_to_bytes
@@ -30,7 +29,7 @@ class LogbookConfig:
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
]
sqlalchemy_filter: Filters | None = None
- entity_filter: EntityFilter | None = None
+ entity_filter: Callable[[str], bool] | None = None
class LazyEventPartialState:
diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py
index a1a7db3ed2c0..57d0a6695c7d 100644
--- a/homeassistant/components/logbook/rest_api.py
+++ b/homeassistant/components/logbook/rest_api.py
@@ -1,6 +1,7 @@
"""Event parser and human readable log generator."""
from __future__ import annotations
+from collections.abc import Callable
from datetime import timedelta
from http import HTTPStatus
from typing import Any, cast
@@ -14,7 +15,6 @@ from homeassistant.components.recorder.filters import Filters
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import InvalidEntityFormatError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
@@ -27,7 +27,7 @@ def async_setup(
hass: HomeAssistant,
conf: ConfigType,
filters: Filters | None,
- entities_filter: EntityFilter | None,
+ entities_filter: Callable[[str], bool] | None,
) -> None:
"""Set up the logbook rest API."""
hass.http.register_view(LogbookView(conf, filters, entities_filter))
@@ -44,7 +44,7 @@ class LogbookView(HomeAssistantView):
self,
config: dict[str, Any],
filters: Filters | None,
- entities_filter: EntityFilter | None,
+ entities_filter: Callable[[str], bool] | None,
) -> None:
"""Initialize the logbook view."""
self.config = config
diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py
index c4e6b9814f49..4afa40cb14f1 100644
--- a/homeassistant/components/logbook/websocket_api.py
+++ b/homeassistant/components/logbook/websocket_api.py
@@ -15,7 +15,6 @@ from homeassistant.components.recorder import get_instance
from homeassistant.components.websocket_api import messages
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
-from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.json import JSON_DUMP
import homeassistant.util.dt as dt_util
@@ -357,7 +356,7 @@ async def ws_event_stream(
)
_unsub()
- entities_filter: EntityFilter | None = None
+ entities_filter: Callable[[str], bool] | None = None
if not event_processor.limited_select:
logbook_config: LogbookConfig = hass.data[DOMAIN]
entities_filter = logbook_config.entity_filter
diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py
index 0d087ef23b72..fe29447aeba4 100644
--- a/homeassistant/components/logger/__init__.py
+++ b/homeassistant/components/logger/__init__.py
@@ -22,6 +22,7 @@ from .const import (
SERVICE_SET_DEFAULT_LEVEL,
SERVICE_SET_LEVEL,
)
+from .const import EVENT_LOGGING_CHANGED # noqa: F401
from .helpers import (
LoggerDomainConfig,
LoggerSettings,
diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py
index a8f22fb17ca2..c16d7f34f0f1 100644
--- a/homeassistant/components/lookin/__init__.py
+++ b/homeassistant/components/lookin/__init__.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
-from datetime import timedelta
import logging
from typing import Any
@@ -26,7 +25,13 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM
+from .const import (
+ DOMAIN,
+ METEO_UPDATE_INTERVAL,
+ PLATFORMS,
+ REMOTE_UPDATE_INTERVAL,
+ TYPE_TO_PLATFORM,
+)
from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator
from .models import LookinData
@@ -107,9 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
push_coordinator,
name=entry.title,
update_method=lookin_protocol.get_meteo_sensor,
- update_interval=timedelta(
- minutes=5
- ), # Updates are pushed (fallback is polling)
+ update_interval=METEO_UPDATE_INTERVAL, # Updates are pushed (fallback is polling)
)
await meteo_coordinator.async_config_entry_first_refresh()
@@ -127,9 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
push_coordinator,
name=f"{entry.title} {uuid}",
update_method=updater,
- update_interval=timedelta(
- seconds=60
- ), # Updates are pushed (fallback is polling)
+ update_interval=REMOTE_UPDATE_INTERVAL, # Updates are pushed (fallback is polling)
)
await coordinator.async_config_entry_first_refresh()
device_coordinators[uuid] = coordinator
diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py
index a48e3ad01a84..8eb96dcefd83 100644
--- a/homeassistant/components/lookin/const.py
+++ b/homeassistant/components/lookin/const.py
@@ -1,6 +1,7 @@
"""The lookin integration constants."""
from __future__ import annotations
+from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
@@ -22,3 +23,12 @@ TYPE_TO_PLATFORM = {
"03": Platform.LIGHT,
"EF": Platform.CLIMATE,
}
+
+NEVER_TIME = -120.0 # Time that will never match time.monotonic()
+ACTIVE_UPDATES_INTERVAL = 4 # Consider active for 4x the update interval
+METEO_UPDATE_INTERVAL = timedelta(minutes=5)
+REMOTE_UPDATE_INTERVAL = timedelta(seconds=60)
+POLLING_FALLBACK_SECONDS = (
+ max(REMOTE_UPDATE_INTERVAL, METEO_UPDATE_INTERVAL).total_seconds()
+ * ACTIVE_UPDATES_INTERVAL
+)
diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py
index 94c4a70f3caa..1bdbb36dd71c 100644
--- a/homeassistant/components/lookin/coordinator.py
+++ b/homeassistant/components/lookin/coordinator.py
@@ -10,12 +10,11 @@ from typing import TypeVar
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS
+
_LOGGER = logging.getLogger(__name__)
_DataT = TypeVar("_DataT")
-NEVER_TIME = -120.0 # Time that will never match time.monotonic()
-ACTIVE_UPDATES_INTERVAL = 3 # Consider active for 3x the update interval
-
class LookinPushCoordinator:
"""Keep track of when the last push update was."""
@@ -32,9 +31,7 @@ class LookinPushCoordinator:
def active(self, interval: timedelta) -> bool:
"""Check if the last push update was recently."""
time_since_last_update = time.monotonic() - self.last_update
- is_active = (
- time_since_last_update < interval.total_seconds() * ACTIVE_UPDATES_INTERVAL
- )
+ is_active = time_since_last_update < POLLING_FALLBACK_SECONDS
_LOGGER.debug(
"%s: push updates active: %s (time_since_last_update=%s)",
self.name,
diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json
index dd9a89f294d5..3c9cd6043dfa 100644
--- a/homeassistant/components/lyric/strings.json
+++ b/homeassistant/components/lyric/strings.json
@@ -17,11 +17,5 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
- },
- "issues": {
- "removed_yaml": {
- "title": "The Honeywell Lyric YAML configuration has been removed",
- "description": "Configuring Honeywell Lyric using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py
index 0ea1fbf2af93..a3ba65be7db1 100644
--- a/homeassistant/components/map/__init__.py
+++ b/homeassistant/components/map/__init__.py
@@ -1,10 +1,13 @@
"""Support for showing device locations."""
from homeassistant.components import frontend
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "map"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the built-in map panel."""
diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py
index 5904e271a6a9..56f31d81d971 100644
--- a/homeassistant/components/marytts/tts.py
+++ b/homeassistant/components/marytts/tts.py
@@ -80,7 +80,7 @@ class MaryTTSProvider(Provider):
"""Return a list of supported options."""
return SUPPORT_OPTIONS
- def get_tts_audio(self, message, language, options=None):
+ def get_tts_audio(self, message, language, options):
"""Load TTS from MaryTTS."""
effects = options[CONF_EFFECT]
diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py
index fbc027091b4d..7d73ceafc7a8 100644
--- a/homeassistant/components/matter/adapter.py
+++ b/homeassistant/components/matter/adapter.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
+from matter_server.client.models.device_types import BridgedDevice
from matter_server.common.models import EventType, ServerInfoMessage
from homeassistant.config_entries import ConfigEntry
@@ -20,6 +21,14 @@ if TYPE_CHECKING:
from matter_server.client.models.node import MatterEndpoint, MatterNode
+def get_clean_name(name: str | None) -> str | None:
+ """Strip spaces and null char from the name."""
+ if name is None:
+ return name
+ name = name.replace("\x00", "")
+ return name.strip() or None
+
+
class MatterAdapter:
"""Connect Matter into Home Assistant."""
@@ -70,11 +79,27 @@ class MatterAdapter:
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
basic_info = endpoint.device_info
- name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName
+ # use (first) DeviceType of the endpoint as fallback product name
+ device_type = next(
+ (
+ x
+ for x in endpoint.device_types
+ if x.device_type != BridgedDevice.device_type
+ ),
+ None,
+ )
+ name = (
+ get_clean_name(basic_info.nodeLabel)
+ or get_clean_name(basic_info.productLabel)
+ or get_clean_name(basic_info.productName)
+ or device_type.__class__.__name__
+ if device_type
+ else None
+ )
# handle bridged devices
bridge_device_id = None
- if endpoint.is_bridged_device:
+ if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint:
bridge_device_id = get_device_id(
server_info,
endpoint.node.endpoints[0],
@@ -91,14 +116,19 @@ class MatterAdapter:
# prefix identifier with 'serial_' to be able to filter it
identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}"))
+ model = (
+ get_clean_name(basic_info.productName) or device_type.__class__.__name__
+ if device_type
+ else None
+ )
dr.async_get(self.hass).async_get_or_create(
name=name,
config_entry_id=self.config_entry.entry_id,
identifiers=identifiers,
hw_version=basic_info.hardwareVersionString,
sw_version=basic_info.softwareVersionString,
- manufacturer=basic_info.vendorName,
- model=basic_info.productName,
+ manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName,
+ model=model,
via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None,
)
diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py
index 4e227d83b44e..61c5d4cd2ff4 100644
--- a/homeassistant/components/matter/cover.py
+++ b/homeassistant/components/matter/cover.py
@@ -2,12 +2,14 @@
from __future__ import annotations
from enum import IntEnum
+from math import floor
from typing import Any
from chip.clusters import Objects as clusters
from homeassistant.components.cover import (
ATTR_POSITION,
+ ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityDescription,
@@ -56,17 +58,20 @@ class MatterCover(MatterEntity, CoverEntity):
"""Representation of a Matter Cover."""
entity_description: CoverEntityDescription
- _attr_supported_features = (
- CoverEntityFeature.OPEN
- | CoverEntityFeature.CLOSE
- | CoverEntityFeature.STOP
- | CoverEntityFeature.SET_POSITION
- )
@property
- def is_closed(self) -> bool:
- """Return true if cover is closed, else False."""
- return self.current_cover_position == 0
+ def is_closed(self) -> bool | None:
+ """Return true if cover is closed, if there is no position report, return None."""
+ if not self._entity_info.endpoint.has_attribute(
+ None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths
+ ):
+ return None
+
+ return (
+ self.current_cover_position == 0
+ if self.current_cover_position is not None
+ else None
+ )
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover movement."""
@@ -88,6 +93,14 @@ class MatterCover(MatterEntity, CoverEntity):
clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100)
)
+ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
+ """Set the cover tilt to a specific position."""
+ position = kwargs[ATTR_TILT_POSITION]
+ await self.send_device_command(
+ # value needs to be inverted and is sent in 100ths
+ clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100)
+ )
+
async def send_device_command(self, command: Any) -> None:
"""Send device command."""
await self.matter_client.send_device_command(
@@ -123,18 +136,45 @@ class MatterCover(MatterEntity, CoverEntity):
self._attr_is_opening = False
self._attr_is_closing = False
- # current position is inverted in matter (100 is closed, 0 is open)
- current_cover_position = self.get_matter_attribute_value(
- clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage
- )
- self._attr_current_cover_position = 100 - current_cover_position
+ if self._entity_info.endpoint.has_attribute(
+ None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths
+ ):
+ # current position is inverted in matter (100 is closed, 0 is open)
+ current_cover_position = self.get_matter_attribute_value(
+ clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths
+ )
+ self._attr_current_cover_position = (
+ 100 - floor(current_cover_position / 100)
+ if current_cover_position is not None
+ else None
+ )
- LOGGER.debug(
- "Current position for %s - raw: %s - corrected: %s",
- self.entity_id,
- current_cover_position,
- self.current_cover_position,
- )
+ LOGGER.debug(
+ "Current position for %s - raw: %s - corrected: %s",
+ self.entity_id,
+ current_cover_position,
+ self.current_cover_position,
+ )
+
+ if self._entity_info.endpoint.has_attribute(
+ None, clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths
+ ):
+ # current tilt position is inverted in matter (100 is closed, 0 is open)
+ current_cover_tilt_position = self.get_matter_attribute_value(
+ clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths
+ )
+ self._attr_current_cover_tilt_position = (
+ 100 - floor(current_cover_tilt_position / 100)
+ if current_cover_tilt_position is not None
+ else None
+ )
+
+ LOGGER.debug(
+ "Current tilt position for %s - raw: %s - corrected: %s",
+ self.entity_id,
+ current_cover_tilt_position,
+ self.current_cover_tilt_position,
+ )
# map matter type to HA deviceclass
device_type: clusters.WindowCovering.Enums.Type = (
@@ -142,6 +182,18 @@ class MatterCover(MatterEntity, CoverEntity):
)
self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING)
+ supported_features = (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ )
+ commands = self.get_matter_attribute_value(
+ clusters.WindowCovering.Attributes.AcceptedCommandList
+ )
+ if clusters.WindowCovering.Commands.GoToLiftPercentage.command_id in commands:
+ supported_features |= CoverEntityFeature.SET_POSITION
+ if clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands:
+ supported_features |= CoverEntityFeature.SET_TILT_POSITION
+ self._attr_supported_features = supported_features
+
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
@@ -150,8 +202,51 @@ DISCOVERY_SCHEMAS = [
entity_description=CoverEntityDescription(key="MatterCover"),
entity_class=MatterCover,
required_attributes=(
- clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage,
clusters.WindowCovering.Attributes.OperationalStatus,
+ clusters.WindowCovering.Attributes.Type,
),
- )
+ absent_attributes=(
+ clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths,
+ clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths,
+ ),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.COVER,
+ entity_description=CoverEntityDescription(key="MatterCoverPositionAwareLift"),
+ entity_class=MatterCover,
+ required_attributes=(
+ clusters.WindowCovering.Attributes.OperationalStatus,
+ clusters.WindowCovering.Attributes.Type,
+ clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths,
+ ),
+ absent_attributes=(
+ clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths,
+ ),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.COVER,
+ entity_description=CoverEntityDescription(key="MatterCoverPositionAwareTilt"),
+ entity_class=MatterCover,
+ required_attributes=(
+ clusters.WindowCovering.Attributes.OperationalStatus,
+ clusters.WindowCovering.Attributes.Type,
+ clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths,
+ ),
+ absent_attributes=(
+ clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths,
+ ),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.COVER,
+ entity_description=CoverEntityDescription(
+ key="MatterCoverPositionAwareLiftAndTilt"
+ ),
+ entity_class=MatterCover,
+ required_attributes=(
+ clusters.WindowCovering.Attributes.OperationalStatus,
+ clusters.WindowCovering.Attributes.Type,
+ clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths,
+ clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths,
+ ),
+ ),
]
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index cee0ee200fe9..4c33d1f27ef6 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -159,5 +159,11 @@
"receiver": {
"name": "Receiver"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index 21c32c9137fe..f3c5c92eaa6b 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.components.media_player.browse_media import (
)
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.frame import report
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
@@ -53,6 +54,9 @@ __all__ = [
]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
def is_media_source_id(media_content_id: str) -> bool:
"""Test if identifier is a media source."""
return URI_SCHEME_REGEX.match(media_content_id) is not None
diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py
index 3cd9fee4fe7a..9a15e81dc22c 100644
--- a/homeassistant/components/melnor/__init__.py
+++ b/homeassistant/components/melnor/__init__.py
@@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.TIME,
]
diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json
index 4a95900aeb39..185899a96564 100644
--- a/homeassistant/components/melnor/manifest.json
+++ b/homeassistant/components/melnor/manifest.json
@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/melnor",
"iot_class": "local_polling",
- "requirements": ["melnor-bluetooth==0.0.20"]
+ "requirements": ["melnor-bluetooth==0.0.24"]
}
diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py
index c750e07f7e84..79b80a6d7b51 100644
--- a/homeassistant/components/melnor/number.py
+++ b/homeassistant/components/melnor/number.py
@@ -8,9 +8,13 @@ from typing import Any
from melnor_bluetooth.device import Valve
-from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.components.number import (
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -44,10 +48,33 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [
native_min_value=1,
icon="mdi:timer-cog-outline",
key="manual_minutes",
- name="Manual Minutes",
+ name="Manual Duration",
+ native_unit_of_measurement=UnitOfTime.MINUTES,
set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value),
state_fn=lambda valve: valve.manual_watering_minutes,
- )
+ ),
+ MelnorZoneNumberEntityDescription(
+ entity_category=EntityCategory.CONFIG,
+ native_max_value=168,
+ native_min_value=1,
+ icon="mdi:calendar-refresh-outline",
+ key="frequency_interval_hours",
+ name="Schedule Interval",
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value),
+ state_fn=lambda valve: valve.frequency.interval_hours,
+ ),
+ MelnorZoneNumberEntityDescription(
+ entity_category=EntityCategory.CONFIG,
+ native_max_value=360,
+ native_min_value=1,
+ icon="mdi:timer-outline",
+ key="frequency_duration_minutes",
+ name="Schedule Duration",
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value),
+ state_fn=lambda valve: valve.frequency.duration_minutes,
+ ),
]
@@ -75,6 +102,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity):
"""A number implementation for a melnor device."""
entity_description: MelnorZoneNumberEntityDescription
+ _attr_mode = NumberMode.BOX
def __init__(
self,
@@ -88,7 +116,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the current value."""
- return self._valve.manual_watering_minutes
+ return self.entity_description.state_fn(self._valve)
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py
index 1061d084ad1b..b4a1d44a2917 100644
--- a/homeassistant/components/melnor/sensor.py
+++ b/homeassistant/components/melnor/sensor.py
@@ -45,6 +45,15 @@ def watering_seconds_left(valve: Valve) -> datetime | None:
return dt_util.utc_from_timestamp(valve.watering_end_time)
+def next_cycle(valve: Valve) -> datetime | None:
+ """Return the value of the next_cycle date, only if the cycle is enabled."""
+
+ if valve.schedule_enabled is True:
+ return valve.next_cycle
+
+ return None
+
+
@dataclass
class MelnorSensorEntityDescriptionMixin:
"""Mixin for required keys."""
@@ -102,6 +111,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [
name="Manual Cycle End",
state_fn=watering_seconds_left,
),
+ MelnorZoneSensorEntityDescription(
+ device_class=SensorDeviceClass.TIMESTAMP,
+ key="next_cycle",
+ name="Next Cycle",
+ state_fn=next_cycle,
+ ),
]
diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py
index eca6f1a98cfb..a2854479abdf 100644
--- a/homeassistant/components/melnor/switch.py
+++ b/homeassistant/components/melnor/switch.py
@@ -47,7 +47,15 @@ ZONE_ENTITY_DESCRIPTIONS = [
key="manual",
on_off_fn=lambda valve, bool: valve.set_is_watering(bool),
state_fn=lambda valve: valve.is_watering,
- )
+ ),
+ MelnorSwitchEntityDescription(
+ device_class=SwitchDeviceClass.SWITCH,
+ icon="mdi:calendar-sync-outline",
+ key="frequency",
+ name="Schedule",
+ on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool),
+ state_fn=lambda valve: valve.schedule_enabled,
+ ),
]
diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py
new file mode 100644
index 000000000000..7abdf62e20c9
--- /dev/null
+++ b/homeassistant/components/melnor/time.py
@@ -0,0 +1,91 @@
+"""Number support for Melnor Bluetooth water timer."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from datetime import time
+from typing import Any
+
+from melnor_bluetooth.device import Valve
+
+from homeassistant.components.time import TimeEntity, TimeEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .models import (
+ MelnorDataUpdateCoordinator,
+ MelnorZoneEntity,
+ get_entities_for_valves,
+)
+
+
+@dataclass
+class MelnorZoneTimeEntityDescriptionMixin:
+ """Mixin for required keys."""
+
+ set_time_fn: Callable[[Valve, time], Coroutine[Any, Any, None]]
+ state_fn: Callable[[Valve], Any]
+
+
+@dataclass
+class MelnorZoneTimeEntityDescription(
+ TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin
+):
+ """Describes Melnor number entity."""
+
+
+ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [
+ MelnorZoneTimeEntityDescription(
+ entity_category=EntityCategory.CONFIG,
+ key="frequency_start_time",
+ name="Schedule Start Time",
+ set_time_fn=lambda valve, value: valve.set_frequency_start_time(value),
+ state_fn=lambda valve: valve.frequency.start_time,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the number platform."""
+
+ coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ async_add_entities(
+ get_entities_for_valves(
+ coordinator,
+ ZONE_ENTITY_DESCRIPTIONS,
+ lambda valve, description: MelnorZoneTime(coordinator, description, valve),
+ )
+ )
+
+
+class MelnorZoneTime(MelnorZoneEntity, TimeEntity):
+ """A time implementation for a melnor device."""
+
+ entity_description: MelnorZoneTimeEntityDescription
+
+ def __init__(
+ self,
+ coordinator: MelnorDataUpdateCoordinator,
+ entity_description: MelnorZoneTimeEntityDescription,
+ valve: Valve,
+ ) -> None:
+ """Initialize a number for a melnor device."""
+ super().__init__(coordinator, entity_description, valve)
+
+ @property
+ def native_value(self) -> time | None:
+ """Return the current value."""
+ return self.entity_description.state_fn(self._valve)
+
+ async def async_set_value(self, value: time) -> None:
+ """Update the current value."""
+ await self.entity_description.set_time_fn(self._valve, value)
diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py
index c676f15336ec..32b095230d90 100644
--- a/homeassistant/components/met/__init__.py
+++ b/homeassistant/components/met/__init__.py
@@ -179,6 +179,6 @@ class MetWeatherData:
raise CannotConnect()
self.current_weather_data = self._weather_data.get_current_weather()
time_zone = dt_util.DEFAULT_TIME_ZONE
- self.daily_forecast = self._weather_data.get_forecast(time_zone, False)
+ self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0)
self.hourly_forecast = self._weather_data.get_forecast(time_zone, True)
return self
diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json
index 5e00e13b808e..32d37e9b4ffc 100644
--- a/homeassistant/components/met/manifest.json
+++ b/homeassistant/components/met/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/met",
"iot_class": "cloud_polling",
"loggers": ["metno"],
- "requirements": ["pyMetno==0.9.0"]
+ "requirements": ["pyMetno==0.10.0"]
}
diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py
index 7deb8f27c68d..9bcd7f533f8f 100644
--- a/homeassistant/components/microsoft/tts.py
+++ b/homeassistant/components/microsoft/tts.py
@@ -1,12 +1,13 @@
"""Support for the Microsoft Cognitive Services text-to-speech service."""
-from http.client import HTTPException
import logging
from pycsspeechtts import pycsspeechtts
+from requests.exceptions import HTTPError
import voluptuous as vol
from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE
+from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES
import homeassistant.helpers.config_validation as cv
CONF_GENDER = "gender"
@@ -17,80 +18,6 @@ CONF_PITCH = "pitch"
CONF_CONTOUR = "contour"
_LOGGER = logging.getLogger(__name__)
-SUPPORTED_LANGUAGES = [
- "ar-eg",
- "ar-sa",
- "bg-bg",
- "ca-es",
- "cs-cz",
- "cy-gb",
- "da-dk",
- "de-at",
- "de-ch",
- "de-de",
- "el-gr",
- "en-au",
- "en-ca",
- "en-gb",
- "en-hk",
- "en-ie",
- "en-in",
- "en-nz",
- "en-ph",
- "en-sg",
- "en-us",
- "en-za",
- "es-ar",
- "es-co",
- "es-es",
- "es-mx",
- "es-us",
- "et-ee",
- "fi-fi",
- "fr-be",
- "fr-ca",
- "fr-ch",
- "fr-fr",
- "ga-ie",
- "gu-in",
- "he-il",
- "hi-in",
- "hr-hr",
- "hu-hu",
- "id-id",
- "is-is",
- "it-it",
- "ja-jp",
- "ko-kr",
- "lt-lt",
- "lv-lv",
- "mr-in",
- "ms-my",
- "mt-mt",
- "nb-no",
- "nl-be",
- "nl-nl",
- "pl-pl",
- "pt-br",
- "pt-pt",
- "ro-ro",
- "ru-ru",
- "sk-sk",
- "sl-si",
- "sv-se",
- "sw-ke",
- "ta-in",
- "te-in",
- "th-th",
- "tr-tr",
- "uk-ua",
- "ur-pk",
- "vi-vn",
- "zh-cn",
- "zh-hk",
- "zh-tw",
-]
-
GENDERS = ["Female", "Male"]
DEFAULT_LANG = "en-us"
@@ -176,7 +103,7 @@ class MicrosoftProvider(Provider):
"""Return a dict include default options."""
return {CONF_GENDER: self._gender, CONF_TYPE: self._type}
- def get_tts_audio(self, message, language, options=None):
+ def get_tts_audio(self, message, language, options):
"""Load TTS from Microsoft."""
if language is None:
language = self._lang
@@ -194,7 +121,7 @@ class MicrosoftProvider(Provider):
contour=self._contour,
text=message,
)
- except HTTPException as ex:
+ except HTTPError as ex:
_LOGGER.error("Error occurred for Microsoft TTS: %s", ex)
return (None, None)
return ("mp3", data)
diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py
index 605c8b6c9d51..27131d9d18fb 100644
--- a/homeassistant/components/mjpeg/__init__.py
+++ b/homeassistant/components/mjpeg/__init__.py
@@ -16,7 +16,7 @@ __all__ = [
]
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the MJPEG IP Camera integration."""
filter_urllib3_logging()
return True
diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py
index 27f5c7a1411e..c2ab3b5768cf 100644
--- a/homeassistant/components/mjpeg/camera.py
+++ b/homeassistant/components/mjpeg/camera.py
@@ -2,14 +2,13 @@
from __future__ import annotations
import asyncio
-from collections.abc import Iterable
-from contextlib import closing
+from collections.abc import AsyncIterator
+from contextlib import suppress
import aiohttp
from aiohttp import web
import async_timeout
-import requests
-from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+import httpx
from yarl import URL
from homeassistant.components.camera import Camera
@@ -29,9 +28,13 @@ from homeassistant.helpers.aiohttp_client import (
)
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
+TIMEOUT = 10
+BUFFER_SIZE = 102400
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -59,11 +62,11 @@ async def async_setup_entry(
)
-def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None:
+async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None:
"""Take in a MJPEG stream object, return the jpg from it."""
data = b""
- for chunk in stream:
+ async for chunk in stream:
data += chunk
jpg_end = data.find(b"\xff\xd9")
@@ -137,12 +140,11 @@ class MjpegCamera(Camera):
self._authentication == HTTP_DIGEST_AUTHENTICATION
or self._still_image_url is None
):
- image = await self.hass.async_add_executor_job(self.camera_image)
- return image
+ return await self._async_digest_camera_image()
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
try:
- async with async_timeout.timeout(10):
+ async with async_timeout.timeout(TIMEOUT):
response = await websession.get(self._still_image_url, auth=self._auth)
image = await response.read()
@@ -156,37 +158,65 @@ class MjpegCamera(Camera):
return None
- def camera_image(
- self, width: int | None = None, height: int | None = None
- ) -> bytes | None:
- """Return a still image response from the camera."""
- if self._username and self._password:
- if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth(
- self._username, self._password
- )
- else:
- auth = HTTPBasicAuth(self._username, self._password)
- req = requests.get(
- self._mjpeg_url,
- auth=auth,
- stream=True,
- timeout=10,
- verify=self._verify_ssl,
- )
- else:
- req = requests.get(self._mjpeg_url, stream=True, timeout=10)
+ def _get_digest_auth(self) -> httpx.DigestAuth:
+ """Return a DigestAuth object."""
+ username = "" if self._username is None else self._username
+ return httpx.DigestAuth(username, self._password)
- with closing(req) as response:
- return extract_image_from_mjpeg(response.iter_content(102400))
+ async def _async_digest_camera_image(self) -> bytes | None:
+ """Return a still image response from the camera using digest authentication."""
+ client = get_async_client(self.hass, verify_ssl=self._verify_ssl)
+ auth = self._get_digest_auth()
+ try:
+ if self._still_image_url:
+ # Fallback to MJPEG stream if still image URL is not available
+ with suppress(asyncio.TimeoutError, httpx.HTTPError):
+ return (
+ await client.get(
+ self._still_image_url, auth=auth, timeout=TIMEOUT
+ )
+ ).content
+
+ async with client.stream(
+ "get", self._mjpeg_url, auth=auth, timeout=TIMEOUT
+ ) as stream:
+ return await async_extract_image_from_mjpeg(
+ stream.aiter_bytes(BUFFER_SIZE)
+ )
+
+ except asyncio.TimeoutError:
+ LOGGER.error("Timeout getting camera image from %s", self.name)
+
+ except httpx.HTTPError as err:
+ LOGGER.error("Error getting new camera image from %s: %s", self.name, err)
+
+ return None
+
+ async def _handle_async_mjpeg_digest_stream(
+ self, request: web.Request
+ ) -> web.StreamResponse | None:
+ """Generate an HTTP MJPEG stream from the camera using digest authentication."""
+ async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream(
+ "get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT
+ ) as stream:
+ response = web.StreamResponse(headers=stream.headers)
+ await response.prepare(request)
+ # Stream until we are done or client disconnects
+ with suppress(asyncio.TimeoutError, httpx.HTTPError):
+ async for chunk in stream.aiter_bytes(BUFFER_SIZE):
+ if not self.hass.is_running:
+ break
+ async with async_timeout.timeout(TIMEOUT):
+ await response.write(chunk)
+ return response
async def handle_async_mjpeg_stream(
self, request: web.Request
) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera."""
- # aiohttp don't support DigestAuth -> Fallback
+ # aiohttp don't support DigestAuth so we use httpx
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- return await super().handle_async_mjpeg_stream(request)
+ return await self._handle_async_mjpeg_digest_stream(request)
# connect to stream
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py
index 44dbd08ffcdf..c637909417c9 100644
--- a/homeassistant/components/moehlenhoff_alpha2/button.py
+++ b/homeassistant/components/moehlenhoff_alpha2/button.py
@@ -6,7 +6,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import Alpha2BaseCoordinator
from .const import DOMAIN
@@ -38,4 +38,4 @@ class Alpha2TimeSyncButton(CoordinatorEntity[Alpha2BaseCoordinator], ButtonEntit
async def async_press(self) -> None:
"""Synchronize current local time from HA instance to base station."""
- await self.coordinator.base.set_datetime(dt.now())
+ await self.coordinator.base.set_datetime(dt_util.now())
diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json
index 0ca982ada82a..cc31ff42edf7 100644
--- a/homeassistant/components/motion_blinds/manifest.json
+++ b/homeassistant/components/motion_blinds/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"iot_class": "local_push",
"loggers": ["motionblinds"],
- "requirements": ["motionblinds==0.6.17"]
+ "requirements": ["motionblinds==0.6.18"]
}
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index d3806044fcc5..3fb6c8d2c485 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -29,10 +29,7 @@ from homeassistant.helpers import config_validation as cv, event, template
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import async_get_platforms
-from homeassistant.helpers.reload import (
- async_integration_yaml_config,
- async_reload_integration_platforms,
-)
+from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
@@ -374,7 +371,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
conf: ConfigType,
) -> None:
"""Forward the config entry setup to the platforms and set up discovery."""
- reload_manual_setup: bool = False
# Local import to avoid circular dependencies
# pylint: disable-next=import-outside-toplevel
from . import device_automation, tag
@@ -399,35 +395,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
# Setup reload service after all platforms have loaded
await async_setup_reload_service()
- # When the entry is reloaded, also reload manual set up items to enable MQTT
- if mqtt_data.reload_entry:
- mqtt_data.reload_entry = False
- reload_manual_setup = True
-
- # When the entry was disabled before, reload manual set up items to enable
- # MQTT again
- if mqtt_data.reload_needed:
- mqtt_data.reload_needed = False
- reload_manual_setup = True
-
- if reload_manual_setup:
- await async_reload_manual_mqtt_items(hass)
await async_forward_entry_setup_and_setup_discovery(entry, conf)
return True
-async def async_reload_manual_mqtt_items(hass: HomeAssistant) -> None:
- """Reload manual configured MQTT items."""
- await hass.services.async_call(
- DOMAIN,
- SERVICE_RELOAD,
- {},
- blocking=True,
- )
-
-
@websocket_api.websocket_command(
{vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str}
)
@@ -570,17 +543,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Cleanup listeners
mqtt_client.cleanup()
- # Trigger reload manual MQTT items at entry setup
- if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False:
- # The entry is disabled reload legacy manual items when
- # the entry is enabled again
- mqtt_data.reload_needed = True
- elif mqtt_entry_status is True:
- # The entry is reloaded:
- # Trigger re-fetching the yaml config at entry setup
- mqtt_data.reload_entry = True
- # Reload the legacy yaml platform to make entities unavailable
- await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS)
# Cleanup entity registry hooks
registry_hooks = mqtt_data.discovery_registry_hooks
while registry_hooks:
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index b685daaf6f18..dbed1c8aa9e8 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -41,12 +41,7 @@ from .const import (
CONF_STATE_TOPIC,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic
@@ -112,13 +107,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT alarm control panels under the alarm_control_panel platform key
-# was deprecated in HA Core 2022.6;
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(alarm.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index d09e31f65f9a..50af9ef8a555 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -43,7 +43,6 @@ from .mixins import (
MqttAvailability,
MqttEntity,
async_setup_entry_helper,
- warn_for_legacy_schema,
)
from .models import MqttValueTemplate, ReceiveMessage
from .util import get_mqtt_data
@@ -69,13 +68,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Binary sensors under the binary_sensor platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(binary_sensor.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py
index f81f78a487a0..46ecc16d3855 100644
--- a/homeassistant/components/mqtt/button.py
+++ b/homeassistant/components/mqtt/button.py
@@ -22,12 +22,7 @@ from .const import (
CONF_QOS,
CONF_RETAIN,
)
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import MqttCommandTemplate
from .util import valid_publish_topic
@@ -46,14 +41,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Buttons under the button platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(button.DOMAIN),
-)
-
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index b3a78f4d2ffe..75ab25efcfa2 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -20,12 +20,7 @@ from . import subscription
from .config import MQTT_BASE_SCHEMA
from .const import CONF_QOS, CONF_TOPIC
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import ReceiveMessage
from .util import valid_subscribe_topic
@@ -56,12 +51,6 @@ PLATFORM_SCHEMA_MODERN = vol.All(
PLATFORM_SCHEMA_BASE.schema,
)
-# Configuring MQTT Camera under the camera platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(camera.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index cd73ee8efb65..e8eabe887f21 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -90,6 +90,7 @@ _LOGGER = logging.getLogger(__name__)
DISCOVERY_COOLDOWN = 2
INITIAL_SUBSCRIBE_COOLDOWN = 1.0
SUBSCRIBE_COOLDOWN = 0.1
+UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
SubscribePayloadType = str | bytes # Only bytes if encoding is None
@@ -375,6 +376,11 @@ class MQTT:
self._simple_subscriptions: dict[str, list[Subscription]] = {}
self._wildcard_subscriptions: list[Subscription] = []
+ # _retained_topics prevents a Subscription from receiving a
+ # retained message more than once per topic. This prevents flooding
+ # already active subscribers when new subscribers subscribe to a topic
+ # which has subscribed messages.
+ self._retained_topics: dict[Subscription, set[str]] = {}
self.connected = False
self._ha_started = asyncio.Event()
self._cleanup_on_unload: list[Callable[[], None]] = []
@@ -387,6 +393,10 @@ class MQTT:
)
self._max_qos: dict[str, int] = {} # topic, max qos
self._pending_subscriptions: dict[str, int] = {} # topic, qos
+ self._unsubscribe_debouncer = EnsureJobAfterCooldown(
+ UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes
+ )
+ self._pending_unsubscribes: set[str] = set() # topic
if self.hass.state == CoreState.running:
self._ha_started.set()
@@ -460,15 +470,15 @@ class MQTT:
msg_info = await self.hass.async_add_executor_job(
self._mqttc.publish, topic, payload, qos, retain
)
- _LOGGER.debug(
- "Transmitting%s message on %s: '%s', mid: %s, qos: %s",
- " retained" if retain else "",
- topic,
- payload,
- msg_info.mid,
- qos,
- )
- _raise_on_error(msg_info.rc)
+ _LOGGER.debug(
+ "Transmitting%s message on %s: '%s', mid: %s, qos: %s",
+ " retained" if retain else "",
+ topic,
+ payload,
+ msg_info.mid,
+ qos,
+ )
+ _raise_on_error(msg_info.rc)
await self._wait_for_mid(msg_info.mid)
async def async_connect(self) -> None:
@@ -510,6 +520,10 @@ class MQTT:
await self._subscribe_debouncer.async_cleanup()
# reset timeout to initial subscribe cooldown
self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN)
+ # stop the unsubscribe debouncer
+ await self._unsubscribe_debouncer.async_cleanup()
+ # make sure the unsubscribes are processed
+ await self._async_perform_unsubscribes()
# wait for ACKs to be processed
async with self._pending_operations_condition:
@@ -573,6 +587,9 @@ class MQTT:
max_qos = max(qos, self._max_qos.setdefault(topic, qos))
self._max_qos[topic] = max_qos
self._pending_subscriptions[topic] = max_qos
+ # Cancel any pending unsubscribe since we are subscribing now
+ if topic in self._pending_unsubscribes:
+ self._pending_unsubscribes.remove(topic)
if queue_only:
return
self._subscribe_debouncer.async_schedule()
@@ -606,24 +623,17 @@ class MQTT:
"""Remove subscription."""
self._async_untrack_subscription(subscription)
self._matching_subscriptions.cache_clear()
+ if subscription in self._retained_topics:
+ del self._retained_topics[subscription]
# Only unsubscribe if currently connected
if self.connected:
- self.hass.async_create_task(self._async_unsubscribe(topic))
+ self._async_unsubscribe(topic)
return async_remove
- async def _async_unsubscribe(self, topic: str) -> None:
- """Unsubscribe from a topic.
-
- This method is a coroutine.
- """
-
- def _client_unsubscribe(topic: str) -> int:
- result, mid = self._mqttc.unsubscribe(topic)
- _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid)
- _raise_on_error(result)
- return mid
-
+ @callback
+ def _async_unsubscribe(self, topic: str) -> None:
+ """Unsubscribe from a topic."""
if self._is_active_subscription(topic):
if self._max_qos[topic] == 0:
return
@@ -634,17 +644,14 @@ class MQTT:
if topic in self._max_qos:
del self._max_qos[topic]
if topic in self._pending_subscriptions:
- # avoid any pending subscription to be executed
+ # Avoid any pending subscription to be executed
del self._pending_subscriptions[topic]
- async with self._paho_lock:
- mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic)
- await self._register_mid(mid)
- self.hass.async_create_task(self._wait_for_mid(mid))
+ self._pending_unsubscribes.add(topic)
+ self._unsubscribe_debouncer.async_schedule()
async def _async_perform_subscriptions(self) -> None:
"""Perform MQTT client subscriptions."""
- subscriptions: dict[str, int]
# Section 3.3.1.3 in the specification:
# http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html
# When sending a PUBLISH Packet to a Client the Server MUST
@@ -657,36 +664,44 @@ class MQTT:
# Since we do not know if a published value is retained we need to
# (re)subscribe, to ensure retained messages are replayed
- def _process_client_subscriptions() -> list[tuple[int, int]]:
- """Initiate all subscriptions on the MQTT client and return the results."""
- subscribe_result_list = []
- for topic, qos in subscriptions.items():
- result, mid = self._mqttc.subscribe(topic, qos)
- subscribe_result_list.append((result, mid))
- _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos)
- return subscribe_result_list
+ if not self._pending_subscriptions:
+ return
- subscriptions = self._pending_subscriptions
+ subscriptions: dict[str, int] = self._pending_subscriptions
self._pending_subscriptions = {}
async with self._paho_lock:
- results = await self.hass.async_add_executor_job(
- _process_client_subscriptions
+ subscription_list = list(subscriptions.items())
+ result, mid = await self.hass.async_add_executor_job(
+ self._mqttc.subscribe, subscription_list
)
+
+ for topic, qos in subscriptions.items():
+ _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos)
self._last_subscribe = time.time()
- tasks: list[Coroutine[Any, Any, None]] = []
- errors: list[int] = []
- for result, mid in results:
- if result == 0:
- tasks.append(self._wait_for_mid(mid))
- else:
- errors.append(result)
+ if result == 0:
+ await self._wait_for_mid(mid)
+ else:
+ _raise_on_error(result)
- if tasks:
- await asyncio.gather(*tasks)
- if errors:
- _raise_on_errors(errors)
+ async def _async_perform_unsubscribes(self) -> None:
+ """Perform pending MQTT client unsubscribes."""
+ if not self._pending_unsubscribes:
+ return
+
+ topics = list(self._pending_unsubscribes)
+ self._pending_unsubscribes = set()
+
+ async with self._paho_lock:
+ result, mid = await self.hass.async_add_executor_job(
+ self._mqttc.unsubscribe, topics
+ )
+ _raise_on_error(result)
+ for topic in topics:
+ _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid)
+
+ await self._wait_for_mid(mid)
def _mqtt_on_connect(
self,
@@ -746,8 +761,9 @@ class MQTT:
async def _async_resubscribe(self) -> None:
"""Resubscribe on reconnect."""
- # Group subscriptions to only re-subscribe once for each topic.
self._max_qos.clear()
+ self._retained_topics.clear()
+ # Group subscriptions to only re-subscribe once for each topic.
keyfunc = attrgetter("topic")
self._async_queue_subscriptions(
[
@@ -791,6 +807,14 @@ class MQTT:
subscriptions = self._matching_subscriptions(msg.topic)
for subscription in subscriptions:
+ if msg.retain:
+ retained_topics = self._retained_topics.setdefault(subscription, set())
+ # Skip if the subscription already received a retained message
+ if msg.topic in retained_topics:
+ continue
+ # Remember the subscription had an initial retained message
+ self._retained_topics[subscription].add(msg.topic)
+
payload: SubscribePayloadType = msg.payload
if subscription.encoding is not None:
try:
@@ -904,22 +928,13 @@ class MQTT:
)
-def _raise_on_errors(result_codes: Iterable[int]) -> None:
+def _raise_on_error(result_code: int) -> None:
"""Raise error if error result."""
# pylint: disable-next=import-outside-toplevel
import paho.mqtt.client as mqtt
- if messages := [
- mqtt.error_string(result_code)
- for result_code in result_codes
- if result_code != 0
- ]:
- raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}")
-
-
-def _raise_on_error(result_code: int) -> None:
- """Raise error if error result."""
- _raise_on_errors((result_code,))
+ if result_code and (message := mqtt.error_string(result_code)):
+ raise HomeAssistantError(f"Error talking to MQTT: {message}")
def _matcher_for_topic(subscription: str) -> Any:
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index 140d9fb128bc..f580df9eab11 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -1,6 +1,7 @@
"""Support for MQTT climate devices."""
from __future__ import annotations
+from abc import ABC, abstractmethod
from collections.abc import Callable
import functools
import logging
@@ -58,12 +59,7 @@ from .const import (
PAYLOAD_NONE,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -389,13 +385,6 @@ PLATFORM_SCHEMA_MODERN = vol.All(
valid_humidity_state_configuration,
)
-# Configuring MQTT Climate under the climate platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(climate.DOMAIN),
-)
-
_DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA)
DISCOVERY_SCHEMA = vol.All(
@@ -447,19 +436,213 @@ async def _async_setup_entity(
async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)])
-class MqttClimate(MqttEntity, ClimateEntity):
+class MqttTemperatureControlEntity(MqttEntity, ABC):
+ """Helper entity class to control temperature.
+
+ MqttTemperatureControlEntity supports shared methods for
+ climate and water_heater platforms.
+ """
+
+ _optimistic: bool
+ _topic: dict[str, Any]
+
+ _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]]
+ _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config: ConfigType,
+ config_entry: ConfigEntry,
+ discovery_data: DiscoveryInfoType | None,
+ ) -> None:
+ """Initialize the temperature controlled device."""
+ self._attr_target_temperature_low = None
+ self._attr_target_temperature_high = None
+ self._feature_preset_mode = False
+ MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
+
+ def add_subscription(
+ self,
+ topics: dict[str, dict[str, Any]],
+ topic: str,
+ msg_callback: Callable[[ReceiveMessage], None],
+ ) -> None:
+ """Add a subscription."""
+ qos: int = self._config[CONF_QOS]
+ if self._topic[topic] is not None:
+ topics[topic] = {
+ "topic": self._topic[topic],
+ "msg_callback": msg_callback,
+ "qos": qos,
+ "encoding": self._config[CONF_ENCODING] or None,
+ }
+
+ def render_template(
+ self, msg: ReceiveMessage, template_name: str
+ ) -> ReceivePayloadType:
+ """Render a template by name."""
+ template = self._value_templates[template_name]
+ return template(msg.payload)
+
+ @callback
+ def handle_climate_attribute_received(
+ self, msg: ReceiveMessage, template_name: str, attr: str
+ ) -> None:
+ """Handle climate attributes coming via MQTT."""
+ payload = self.render_template(msg, template_name)
+ if not payload:
+ _LOGGER.debug(
+ "Invalid empty payload for attribute %s, ignoring update",
+ attr,
+ )
+ return
+ if payload == PAYLOAD_NONE:
+ setattr(self, attr, None)
+ get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
+ return
+ try:
+ setattr(self, attr, float(payload))
+ get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
+ except ValueError:
+ _LOGGER.error("Could not parse %s from %s", template_name, payload)
+
+ def prepare_subscribe_topics(
+ self, topics: dict[str, dict[str, Any]]
+ ) -> None: # noqa: C901
+ """(Re)Subscribe to topics."""
+
+ @callback
+ @log_messages(self.hass, self.entity_id)
+ def handle_current_temperature_received(msg: ReceiveMessage) -> None:
+ """Handle current temperature coming via MQTT."""
+ self.handle_climate_attribute_received(
+ msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature"
+ )
+
+ self.add_subscription(
+ topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received
+ )
+
+ @callback
+ @log_messages(self.hass, self.entity_id)
+ def handle_target_temperature_received(msg: ReceiveMessage) -> None:
+ """Handle target temperature coming via MQTT."""
+ self.handle_climate_attribute_received(
+ msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature"
+ )
+
+ self.add_subscription(
+ topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received
+ )
+
+ @callback
+ @log_messages(self.hass, self.entity_id)
+ def handle_temperature_low_received(msg: ReceiveMessage) -> None:
+ """Handle target temperature low coming via MQTT."""
+ self.handle_climate_attribute_received(
+ msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low"
+ )
+
+ self.add_subscription(
+ topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received
+ )
+
+ @callback
+ @log_messages(self.hass, self.entity_id)
+ def handle_temperature_high_received(msg: ReceiveMessage) -> None:
+ """Handle target temperature high coming via MQTT."""
+ self.handle_climate_attribute_received(
+ msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high"
+ )
+
+ self.add_subscription(
+ topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received
+ )
+
+ self._sub_state = subscription.async_prepare_subscribe_topics(
+ self.hass, self._sub_state, topics
+ )
+
+ async def _subscribe_topics(self) -> None:
+ """(Re)Subscribe to topics."""
+ await subscription.async_subscribe_topics(self.hass, self._sub_state)
+
+ async def _publish(self, topic: str, payload: PublishPayloadType) -> None:
+ if self._topic[topic] is not None:
+ await self.async_publish(
+ self._topic[topic],
+ payload,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN],
+ self._config[CONF_ENCODING],
+ )
+
+ async def _set_climate_attribute(
+ self,
+ temp: float | None,
+ cmnd_topic: str,
+ cmnd_template: str,
+ state_topic: str,
+ attr: str,
+ ) -> bool:
+ if temp is None:
+ return False
+ changed = False
+ if self._optimistic or self._topic[state_topic] is None:
+ # optimistic mode
+ changed = True
+ setattr(self, attr, temp)
+
+ payload = self._command_templates[cmnd_template](temp)
+ await self._publish(cmnd_topic, payload)
+ return changed
+
+ @abstractmethod
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set hvac mode."""
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperatures."""
+ operation_mode: HVACMode | None
+ if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
+ await self.async_set_hvac_mode(operation_mode)
+
+ changed = await self._set_climate_attribute(
+ kwargs.get(ATTR_TEMPERATURE),
+ CONF_TEMP_COMMAND_TOPIC,
+ CONF_TEMP_COMMAND_TEMPLATE,
+ CONF_TEMP_STATE_TOPIC,
+ "_attr_target_temperature",
+ )
+
+ changed |= await self._set_climate_attribute(
+ kwargs.get(ATTR_TARGET_TEMP_LOW),
+ CONF_TEMP_LOW_COMMAND_TOPIC,
+ CONF_TEMP_LOW_COMMAND_TEMPLATE,
+ CONF_TEMP_LOW_STATE_TOPIC,
+ "_attr_target_temperature_low",
+ )
+
+ changed |= await self._set_climate_attribute(
+ kwargs.get(ATTR_TARGET_TEMP_HIGH),
+ CONF_TEMP_HIGH_COMMAND_TOPIC,
+ CONF_TEMP_HIGH_COMMAND_TEMPLATE,
+ CONF_TEMP_HIGH_STATE_TOPIC,
+ "_attr_target_temperature_high",
+ )
+
+ if not changed:
+ return
+ self.async_write_ha_state()
+
+
+class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[misc]
"""Representation of an MQTT climate device."""
_entity_id_format = climate.ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED
- _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]]
- _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
- _feature_preset_mode: bool
- _optimistic: bool
- _optimistic_preset_mode: bool
- _topic: dict[str, Any]
-
def __init__(
self,
hass: HomeAssistant,
@@ -473,9 +656,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
self._attr_hvac_mode = None
self._attr_is_aux_heat = None
self._attr_swing_mode = None
- self._attr_target_temperature_low = None
- self._attr_target_temperature_high = None
- MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
+ MqttTemperatureControlEntity.__init__(
+ self, hass, config, config_entry, discovery_data
+ )
@staticmethod
def config_schema() -> vol.Schema:
@@ -594,32 +777,12 @@ class MqttClimate(MqttEntity, ClimateEntity):
def _prepare_subscribe_topics(self) -> None: # noqa: C901
"""(Re)Subscribe to topics."""
topics: dict[str, dict[str, Any]] = {}
- qos: int = self._config[CONF_QOS]
-
- def add_subscription(
- topics: dict[str, dict[str, Any]],
- topic: str,
- msg_callback: Callable[[ReceiveMessage], None],
- ) -> None:
- if self._topic[topic] is not None:
- topics[topic] = {
- "topic": self._topic[topic],
- "msg_callback": msg_callback,
- "qos": qos,
- "encoding": self._config[CONF_ENCODING] or None,
- }
-
- def render_template(
- msg: ReceiveMessage, template_name: str
- ) -> ReceivePayloadType:
- template = self._value_templates[template_name]
- return template(msg.payload)
@callback
@log_messages(self.hass, self.entity_id)
def handle_action_received(msg: ReceiveMessage) -> None:
"""Handle receiving action via MQTT."""
- payload = render_template(msg, CONF_ACTION_TEMPLATE)
+ payload = self.render_template(msg, CONF_ACTION_TEMPLATE)
if not payload or payload == PAYLOAD_NONE:
_LOGGER.debug(
"Invalid %s action: %s, ignoring",
@@ -638,87 +801,17 @@ class MqttClimate(MqttEntity, ClimateEntity):
return
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
- add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received)
-
- @callback
- def handle_climate_attribute_received(
- msg: ReceiveMessage, template_name: str, attr: str
- ) -> None:
- """Handle climate attributes coming via MQTT."""
- payload = render_template(msg, template_name)
- if not payload:
- _LOGGER.debug(
- "Invalid empty payload for attribute %s, ignoring update",
- attr,
- )
- return
- if payload == PAYLOAD_NONE:
- setattr(self, attr, None)
- get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
- return
- try:
- setattr(self, attr, float(payload))
- get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
- except ValueError:
- _LOGGER.error("Could not parse %s from %s", template_name, payload)
-
- @callback
- @log_messages(self.hass, self.entity_id)
- def handle_current_temperature_received(msg: ReceiveMessage) -> None:
- """Handle current temperature coming via MQTT."""
- handle_climate_attribute_received(
- msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature"
- )
-
- add_subscription(
- topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received
- )
-
- @callback
- @log_messages(self.hass, self.entity_id)
- def handle_target_temperature_received(msg: ReceiveMessage) -> None:
- """Handle target temperature coming via MQTT."""
- handle_climate_attribute_received(
- msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature"
- )
-
- add_subscription(
- topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received
- )
-
- @callback
- @log_messages(self.hass, self.entity_id)
- def handle_temperature_low_received(msg: ReceiveMessage) -> None:
- """Handle target temperature low coming via MQTT."""
- handle_climate_attribute_received(
- msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low"
- )
-
- add_subscription(
- topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received
- )
-
- @callback
- @log_messages(self.hass, self.entity_id)
- def handle_temperature_high_received(msg: ReceiveMessage) -> None:
- """Handle target temperature high coming via MQTT."""
- handle_climate_attribute_received(
- msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high"
- )
-
- add_subscription(
- topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received
- )
+ self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received)
@callback
@log_messages(self.hass, self.entity_id)
def handle_current_humidity_received(msg: ReceiveMessage) -> None:
"""Handle current humidity coming via MQTT."""
- handle_climate_attribute_received(
+ self.handle_climate_attribute_received(
msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity"
)
- add_subscription(
+ self.add_subscription(
topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received
)
@@ -727,11 +820,11 @@ class MqttClimate(MqttEntity, ClimateEntity):
def handle_target_humidity_received(msg: ReceiveMessage) -> None:
"""Handle target humidity coming via MQTT."""
- handle_climate_attribute_received(
+ self.handle_climate_attribute_received(
msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity"
)
- add_subscription(
+ self.add_subscription(
topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received
)
@@ -740,7 +833,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
msg: ReceiveMessage, template_name: str, attr: str, mode_list: str
) -> None:
"""Handle receiving listed mode via MQTT."""
- payload = render_template(msg, template_name)
+ payload = self.render_template(msg, template_name)
if payload not in self._config[mode_list]:
_LOGGER.error("Invalid %s mode: %s", mode_list, payload)
@@ -756,7 +849,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
msg, CONF_MODE_STATE_TEMPLATE, "_attr_hvac_mode", CONF_MODE_LIST
)
- add_subscription(topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received)
+ self.add_subscription(
+ topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received
+ )
@callback
@log_messages(self.hass, self.entity_id)
@@ -769,7 +864,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
CONF_FAN_MODE_LIST,
)
- add_subscription(topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received)
+ self.add_subscription(
+ topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received
+ )
@callback
@log_messages(self.hass, self.entity_id)
@@ -782,7 +879,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
CONF_SWING_MODE_LIST,
)
- add_subscription(
+ self.add_subscription(
topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received
)
@@ -791,7 +888,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
msg: ReceiveMessage, template_name: str, attr: str
) -> None:
"""Handle receiving on/off mode via MQTT."""
- payload = render_template(msg, template_name)
+ payload = self.render_template(msg, template_name)
payload_on: str = self._config[CONF_PAYLOAD_ON]
payload_off: str = self._config[CONF_PAYLOAD_OFF]
@@ -817,13 +914,13 @@ class MqttClimate(MqttEntity, ClimateEntity):
msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat"
)
- add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received)
+ self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received)
@callback
@log_messages(self.hass, self.entity_id)
def handle_preset_mode_received(msg: ReceiveMessage) -> None:
"""Handle receiving preset mode via MQTT."""
- preset_mode = render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE)
+ preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE)
if preset_mode in [PRESET_NONE, PAYLOAD_NONE]:
self._attr_preset_mode = PRESET_NONE
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
@@ -831,7 +928,10 @@ class MqttClimate(MqttEntity, ClimateEntity):
if not preset_mode:
_LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic)
return
- if not self.preset_modes or preset_mode not in self.preset_modes:
+ if (
+ not self._attr_preset_modes
+ or preset_mode not in self._attr_preset_modes
+ ):
_LOGGER.warning(
"'%s' received on topic %s. '%s' is not a valid preset mode",
msg.payload,
@@ -843,81 +943,11 @@ class MqttClimate(MqttEntity, ClimateEntity):
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
- add_subscription(
+ self.add_subscription(
topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received
)
- self._sub_state = subscription.async_prepare_subscribe_topics(
- self.hass, self._sub_state, topics
- )
-
- async def _subscribe_topics(self) -> None:
- """(Re)Subscribe to topics."""
- await subscription.async_subscribe_topics(self.hass, self._sub_state)
-
- async def _publish(self, topic: str, payload: PublishPayloadType) -> None:
- if self._topic[topic] is not None:
- await self.async_publish(
- self._topic[topic],
- payload,
- self._config[CONF_QOS],
- self._config[CONF_RETAIN],
- self._config[CONF_ENCODING],
- )
-
- async def _set_climate_attribute(
- self,
- temp: float | None,
- cmnd_topic: str,
- cmnd_template: str,
- state_topic: str,
- attr: str,
- ) -> bool:
- if temp is None:
- return False
- changed = False
- if self._optimistic or self._topic[state_topic] is None:
- # optimistic mode
- changed = True
- setattr(self, attr, temp)
-
- payload = self._command_templates[cmnd_template](temp)
- await self._publish(cmnd_topic, payload)
- return changed
-
- async def async_set_temperature(self, **kwargs: Any) -> None:
- """Set new target temperatures."""
- operation_mode: HVACMode | None
- if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
- await self.async_set_hvac_mode(operation_mode)
-
- changed = await self._set_climate_attribute(
- kwargs.get(ATTR_TEMPERATURE),
- CONF_TEMP_COMMAND_TOPIC,
- CONF_TEMP_COMMAND_TEMPLATE,
- CONF_TEMP_STATE_TOPIC,
- "_attr_target_temperature",
- )
-
- changed |= await self._set_climate_attribute(
- kwargs.get(ATTR_TARGET_TEMP_LOW),
- CONF_TEMP_LOW_COMMAND_TOPIC,
- CONF_TEMP_LOW_COMMAND_TEMPLATE,
- CONF_TEMP_LOW_STATE_TOPIC,
- "_attr_target_temperature_low",
- )
-
- changed |= await self._set_climate_attribute(
- kwargs.get(ATTR_TARGET_TEMP_HIGH),
- CONF_TEMP_HIGH_COMMAND_TOPIC,
- CONF_TEMP_HIGH_COMMAND_TEMPLATE,
- CONF_TEMP_HIGH_STATE_TOPIC,
- "_attr_target_temperature_high",
- )
-
- if not changed:
- return
- self.async_write_ha_state()
+ self.prepare_subscribe_topics(topics)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index e7c458e6822f..da2f1b4496d4 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -46,12 +46,7 @@ from .const import (
DEFAULT_OPTIMISTIC,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic
@@ -210,12 +205,6 @@ PLATFORM_SCHEMA_MODERN = vol.All(
validate_options,
)
-# Configuring MQTT Covers under the cover platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(cover.DOMAIN),
-)
-
DISCOVERY_SCHEMA = vol.All(
cv.removed("tilt_invert_state"),
_PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index b55c37546961..a9c4017593c9 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -28,17 +28,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import subscription
-from .config import MQTT_RO_SCHEMA
+from .config import MQTT_BASE_SCHEMA
from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC
from .debug_info import log_messages
from .mixins import (
+ CONF_JSON_ATTRS_TOPIC,
MQTT_ENTITY_COMMON_SCHEMA,
MqttEntity,
async_setup_entry_helper,
- warn_for_legacy_schema,
)
from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType
-from .util import get_mqtt_data
+from .util import get_mqtt_data, valid_subscribe_topic
CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home"
@@ -47,8 +47,20 @@ CONF_SOURCE_TYPE = "source_type"
DEFAULT_PAYLOAD_RESET = "None"
DEFAULT_SOURCE_TYPE = SourceType.GPS
-PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
+
+def valid_config(config: ConfigType) -> ConfigType:
+ """Check if there is a state topic or json_attributes_topic."""
+ if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config:
+ raise vol.MultipleInvalid(
+ f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}"
+ )
+ return config
+
+
+PLATFORM_SCHEMA_MODERN_BASE = MQTT_BASE_SCHEMA.extend(
{
+ vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string,
vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string,
@@ -56,15 +68,14 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend(
vol.Optional(CONF_SOURCE_TYPE, default=DEFAULT_SOURCE_TYPE): vol.In(
SOURCE_TYPES
),
- }
+ },
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
+PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_MODERN_BASE, valid_config)
-DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
-# Configuring MQTT Device Trackers under the device_tracker platform key was deprecated
-# in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(warn_for_legacy_schema(device_tracker.DOMAIN))
+DISCOVERY_SCHEMA = vol.All(
+ PLATFORM_SCHEMA_MODERN_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_config
+)
async def async_setup_entry(
@@ -138,12 +149,15 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
+ state_topic: str | None = self._config.get(CONF_STATE_TOPIC)
+ if state_topic is None:
+ return
self._sub_state = subscription.async_prepare_subscribe_topics(
self.hass,
self._sub_state,
{
"state_topic": {
- "topic": self._config[CONF_STATE_TOPIC],
+ "topic": state_topic,
"msg_callback": message_received,
"qos": self._config[CONF_QOS],
}
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index 342e7d121f2e..0c0032ec8eb0 100644
--- a/homeassistant/components/mqtt/discovery.py
+++ b/homeassistant/components/mqtt/discovery.py
@@ -113,7 +113,7 @@ async def async_start( # noqa: C901
"Received message on illegal discovery topic '%s'. The topic"
" contains "
"not allowed characters. For more information see "
- "https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic"
+ "https://www.home-assistant.io/integrations/mqtt/#discovery-topic"
),
topic,
)
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index e8259c608096..f5e92d8ecf95 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -50,12 +50,7 @@ from .const import (
PAYLOAD_NONE,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MessageCallbackType,
MqttCommandTemplate,
@@ -181,12 +176,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Fans under the fan platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(fan.DOMAIN),
-)
-
PLATFORM_SCHEMA_MODERN = vol.All(
_PLATFORM_SCHEMA_BASE,
valid_speed_range_configuration,
diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py
index 2c6dae54f4c8..f00944fc0915 100644
--- a/homeassistant/components/mqtt/humidifier.py
+++ b/homeassistant/components/mqtt/humidifier.py
@@ -45,12 +45,7 @@ from .const import (
PAYLOAD_NONE,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -151,13 +146,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Humidifiers under the humidifier platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(humidifier.DOMAIN),
-)
-
PLATFORM_SCHEMA_MODERN = vol.All(
_PLATFORM_SCHEMA_BASE,
valid_humidity_range_configuration,
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
index f91f76c6a82c..5cd42ef19340 100644
--- a/homeassistant/components/mqtt/light/__init__.py
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from ..mixins import async_setup_entry_helper, warn_for_legacy_schema
+from ..mixins import async_setup_entry_helper
from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA
from .schema_basic import (
DISCOVERY_SCHEMA_BASIC,
@@ -58,12 +58,6 @@ DISCOVERY_SCHEMA = vol.All(
validate_mqtt_light_discovery,
)
-# Configuring MQTT Lights under the light platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(light.DOMAIN),
-)
-
PLATFORM_SCHEMA_MODERN = vol.All(
MQTT_LIGHT_SCHEMA_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA),
validate_mqtt_light_modern,
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index 0598c0354ed9..966cbc211055 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -33,12 +33,7 @@ from .const import (
CONF_STATE_TOPIC,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -94,12 +89,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Locks under the lock platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(lock.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
STATE_CONFIG_KEYS = [
diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py
index 388264380915..46744c4d65d1 100644
--- a/homeassistant/components/mqtt/mixins.py
+++ b/homeassistant/components/mqtt/mixins.py
@@ -28,16 +28,13 @@ from homeassistant.const import (
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
)
-from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.device_registry import (
- EVENT_DEVICE_REGISTRY_UPDATED,
- DeviceEntry,
-)
+from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@@ -49,8 +46,10 @@ from homeassistant.helpers.entity import (
async_generate_entity_id,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_track_entity_registry_updated_event
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.helpers.event import (
+ async_track_device_registry_updated_event,
+ async_track_entity_registry_updated_event,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.json import json_loads
@@ -231,51 +230,6 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend(
)
-def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]:
- """Warn once when a legacy platform schema is used."""
- warned = set()
-
- def validator(config: ConfigType) -> ConfigType:
- """Return a validator."""
- nonlocal warned
-
- # Logged error and repair can be removed from HA 2023.6
- if domain in warned:
- return config
-
- _LOGGER.error(
- (
- "Manually configured MQTT %s(s) found under platform key '%s', "
- "please move to the mqtt integration key, see "
- "https://www.home-assistant.io/integrations/%s.mqtt/"
- ),
- domain,
- domain,
- domain,
- )
- warned.add(domain)
- # Register a repair
- async_create_issue(
- async_get_hass(),
- DOMAIN,
- f"deprecated_yaml_{domain}",
- breaks_in_ha_version="2022.12.0", # Warning first added in 2022.6.0
- is_fixable=False,
- severity=IssueSeverity.ERROR,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "more_info_url": (
- "https://www.home-assistant.io"
- f"/integrations/{domain}.mqtt/#new_format"
- ),
- "platform": domain,
- },
- )
- return config
-
- return validator
-
-
class SetupEntity(Protocol):
"""Protocol type for async_setup_entities."""
@@ -680,8 +634,8 @@ class MqttDiscoveryDeviceUpdate(ABC):
)
config_entry.async_on_unload(self._entry_unload)
if device_id is not None:
- self._remove_device_updated = hass.bus.async_listen(
- EVENT_DEVICE_REGISTRY_UPDATED, self._async_device_removed
+ self._remove_device_updated = async_track_device_registry_updated_event(
+ hass, device_id, self._async_device_removed
)
_LOGGER.info(
"%s %s has been initialized",
@@ -1182,19 +1136,16 @@ def async_removed_from_device(
hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str
) -> bool:
"""Check if the passed event indicates MQTT was removed from a device."""
- device_id: str = event.data["device_id"]
- if event.data["action"] not in ("remove", "update"):
+ action: str = event.data["action"]
+ if action not in ("remove", "update"):
return False
- if device_id != mqtt_device_id:
- return False
-
- if event.data["action"] == "update":
+ if action == "update":
if "config_entries" not in event.data["changes"]:
return False
device_registry = dr.async_get(hass)
if (
- device_entry := device_registry.async_get(device_id)
+ device_entry := device_registry.async_get(event.data["device_id"])
) and config_entry_id in device_entry.config_entries:
# Not removed from device
return False
diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py
index eac333e2a7a7..aeae184dc89d 100644
--- a/homeassistant/components/mqtt/models.py
+++ b/homeassistant/components/mqtt/models.py
@@ -307,11 +307,9 @@ class MqttData:
integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict)
last_discovery: float = 0.0
reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list)
- reload_entry: bool = False
reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field(
default_factory=dict
)
- reload_needed: bool = False
state_write_requests: EntityTopicState = field(default_factory=EntityTopicState)
subscriptions_to_restore: list[Subscription] = field(default_factory=list)
tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict)
diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py
index 1ab14b2b4f8f..c0cb00211e33 100644
--- a/homeassistant/components/mqtt/number.py
+++ b/homeassistant/components/mqtt/number.py
@@ -42,12 +42,7 @@ from .const import (
CONF_STATE_TOPIC,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -107,12 +102,6 @@ PLATFORM_SCHEMA_MODERN = vol.All(
validate_config,
)
-# Configuring MQTT Number under the number platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(number.DOMAIN),
-)
-
DISCOVERY_SCHEMA = vol.All(
_PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
validate_config,
diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py
index dd7f3347845f..f716e4fe46f8 100644
--- a/homeassistant/components/mqtt/scene.py
+++ b/homeassistant/components/mqtt/scene.py
@@ -24,7 +24,6 @@ from .mixins import (
MQTT_AVAILABILITY_SCHEMA,
MqttEntity,
async_setup_entry_helper,
- warn_for_legacy_schema,
)
from .util import valid_publish_topic
@@ -46,12 +45,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_AVAILABILITY_SCHEMA.schema)
-# Configuring MQTT Scenes under the scene platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(scene.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py
index b783a001f150..26e72af91923 100644
--- a/homeassistant/components/mqtt/select.py
+++ b/homeassistant/components/mqtt/select.py
@@ -28,12 +28,7 @@ from .const import (
CONF_STATE_TOPIC,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -65,11 +60,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
},
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Select under the select platform key was deprecated in HA Core 2022.6
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(select.DOMAIN),
-)
-
DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA))
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 9de442926a0c..e4b5f61bda0c 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -45,7 +45,6 @@ from .mixins import (
MqttAvailability,
MqttEntity,
async_setup_entry_helper,
- warn_for_legacy_schema,
)
from .models import (
MqttValueTemplate,
@@ -53,7 +52,7 @@ from .models import (
ReceiveMessage,
ReceivePayloadType,
)
-from .util import get_mqtt_data, valid_subscribe_topic
+from .util import get_mqtt_data
_LOGGER = logging.getLogger(__name__)
@@ -73,36 +72,11 @@ DEFAULT_NAME = "MQTT Sensor"
DEFAULT_FORCE_UPDATE = False
-def validate_options(conf: ConfigType) -> ConfigType:
- """Validate options.
-
- If last reset topic is present it must be same as the state topic.
- """
- if (
- CONF_LAST_RESET_TOPIC in conf
- and CONF_STATE_TOPIC in conf
- and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC]
- ):
- _LOGGER.warning(
- "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC
- )
-
- if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf:
- _LOGGER.warning(
- "'%s' must be set if '%s' is set",
- CONF_LAST_RESET_VALUE_TEMPLATE,
- CONF_LAST_RESET_TOPIC,
- )
-
- return conf
-
-
_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
- vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int,
@@ -112,20 +86,17 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
PLATFORM_SCHEMA_MODERN = vol.All(
+ # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840
+ # Removed in HA Core 2023.6.0
+ cv.removed(CONF_LAST_RESET_TOPIC),
_PLATFORM_SCHEMA_BASE,
- validate_options,
-)
-
-# Configuring MQTT Sensors under the sensor platform key was deprecated in
-# HA Core 2022.6
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(sensor.DOMAIN),
)
DISCOVERY_SCHEMA = vol.All(
- cv.deprecated(CONF_LAST_RESET_TOPIC),
+ # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840
+ # Removed in HA Core 2023.6.0
+ cv.removed(CONF_LAST_RESET_TOPIC),
_PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
- validate_options,
)
@@ -319,10 +290,7 @@ class MqttSensor(MqttEntity, RestoreSensor):
def message_received(msg: ReceiveMessage) -> None:
"""Handle new MQTT messages."""
_update_state(msg)
- if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and (
- CONF_LAST_RESET_TOPIC not in self._config
- or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC]
- ):
+ if CONF_LAST_RESET_VALUE_TEMPLATE in self._config:
_update_last_reset(msg)
get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
@@ -333,24 +301,6 @@ class MqttSensor(MqttEntity, RestoreSensor):
"encoding": self._config[CONF_ENCODING] or None,
}
- @callback
- @log_messages(self.hass, self.entity_id)
- def last_reset_message_received(msg: ReceiveMessage) -> None:
- """Handle new last_reset messages."""
- _update_last_reset(msg)
- get_mqtt_data(self.hass).state_write_requests.write_state_request(self)
-
- if (
- CONF_LAST_RESET_TOPIC in self._config
- and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC]
- ):
- topics["last_reset_topic"] = {
- "topic": self._config[CONF_LAST_RESET_TOPIC],
- "msg_callback": last_reset_message_received,
- "qos": self._config[CONF_QOS],
- "encoding": self._config[CONF_ENCODING] or None,
- }
-
self._sub_state = subscription.async_prepare_subscribe_topics(
self.hass, self._sub_state, topics
)
diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py
index e279deb70b36..4134dd971486 100644
--- a/homeassistant/components/mqtt/siren.py
+++ b/homeassistant/components/mqtt/siren.py
@@ -50,12 +50,7 @@ from .const import (
PAYLOAD_NONE,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import (
MqttCommandTemplate,
MqttValueTemplate,
@@ -96,12 +91,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
},
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Sirens under the siren platform key was deprecated in HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(siren.DOMAIN),
-)
-
DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA))
MQTT_SIREN_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index b55fa5779b8e..b06794c9b329 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -1,14 +1,4 @@
{
- "issues": {
- "deprecated_yaml": {
- "title": "Your manually configured MQTT {platform}(s) needs attention",
- "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information."
- },
- "deprecated_yaml_broker_settings": {
- "title": "Deprecated MQTT settings found in `configuration.yaml`",
- "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information."
- }
- },
"config": {
"step": {
"broker": {
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index 521b08d27489..7f4f609f265e 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -37,12 +37,7 @@ from .const import (
PAYLOAD_NONE,
)
from .debug_info import log_messages
-from .mixins import (
- MQTT_ENTITY_COMMON_SCHEMA,
- MqttEntity,
- async_setup_entry_helper,
- warn_for_legacy_schema,
-)
+from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper
from .models import MqttValueTemplate, ReceiveMessage
from .util import get_mqtt_data
@@ -64,13 +59,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
-# Configuring MQTT Switches under the switch platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(switch.DOMAIN),
-)
-
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py
index 86d94883c906..068bc183ec41 100644
--- a/homeassistant/components/mqtt/vacuum/__init__.py
+++ b/homeassistant/components/mqtt/vacuum/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from ..mixins import async_setup_entry_helper, warn_for_legacy_schema
+from ..mixins import async_setup_entry_helper
from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE
from .schema_legacy import (
DISCOVERY_SCHEMA_LEGACY,
@@ -46,13 +46,6 @@ DISCOVERY_SCHEMA = vol.All(
MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery
)
-# Configuring MQTT Vacuums under the vacuum platform key was deprecated in
-# HA Core 2022.6
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA = vol.All(
- warn_for_legacy_schema(vacuum.DOMAIN),
-)
-
PLATFORM_SCHEMA_MODERN = vol.All(
MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern
)
diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py
index 8a2912f19108..6cab62cdb5d0 100644
--- a/homeassistant/components/mqtt/vacuum/schema_legacy.py
+++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py
@@ -8,7 +8,6 @@ import voluptuous as vol
from homeassistant.components.vacuum import (
ATTR_STATUS,
- DOMAIN as VACUUM_DOMAIN,
ENTITY_ID_FORMAT,
VacuumEntity,
VacuumEntityFeature,
@@ -26,7 +25,7 @@ from .. import subscription
from ..config import MQTT_BASE_SCHEMA
from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN
from ..debug_info import log_messages
-from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema
+from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity
from ..models import (
MqttValueTemplate,
PayloadSentinel,
@@ -160,13 +159,6 @@ PLATFORM_SCHEMA_LEGACY_MODERN = (
.extend(MQTT_VACUUM_SCHEMA.schema)
)
-# Configuring MQTT Vacuums under the vacuum platform key was deprecated in
-# HA Core 2022.6;
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA_LEGACY = vol.All(
- warn_for_legacy_schema(VACUUM_DOMAIN),
-)
-
DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend(
{}, extra=vol.REMOVE_EXTRA
)
diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py
index 3f8c6953abee..385d60a38866 100644
--- a/homeassistant/components/mqtt/vacuum/schema_state.py
+++ b/homeassistant/components/mqtt/vacuum/schema_state.py
@@ -6,7 +6,6 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components.vacuum import (
- DOMAIN as VACUUM_DOMAIN,
ENTITY_ID_FORMAT,
STATE_CLEANING,
STATE_DOCKED,
@@ -39,7 +38,7 @@ from ..const import (
CONF_STATE_TOPIC,
)
from ..debug_info import log_messages
-from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema
+from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity
from ..models import ReceiveMessage
from ..util import get_mqtt_data, valid_publish_topic
from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED
@@ -155,13 +154,6 @@ PLATFORM_SCHEMA_STATE_MODERN = (
.extend(MQTT_VACUUM_SCHEMA.schema)
)
-# Configuring MQTT Vacuums under the vacuum platform key was deprecated in
-# HA Core 2022.6;
-# Setup for the legacy YAML format was removed in HA Core 2022.12
-PLATFORM_SCHEMA_STATE = vol.All(
- warn_for_legacy_schema(VACUUM_DOMAIN),
-)
-
DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py
index 62a10c1bd000..af370fe82f3d 100644
--- a/homeassistant/components/mqtt_eventstream/__init__.py
+++ b/homeassistant/components/mqtt_eventstream/__init__.py
@@ -1,5 +1,6 @@
"""Connect two Home Assistant instances via MQTT."""
import json
+import logging
import voluptuous as vol
@@ -21,6 +22,8 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType
+_LOGGER = logging.getLogger(__name__)
+
DOMAIN = "mqtt_eventstream"
CONF_PUBLISH_TOPIC = "publish_topic"
CONF_SUBSCRIBE_TOPIC = "subscribe_topic"
@@ -54,6 +57,11 @@ BLOCKED_EVENTS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the MQTT eventstream component."""
+ # 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 False
+
conf = config.get(DOMAIN, {})
pub_topic = conf.get(CONF_PUBLISH_TOPIC)
sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC)
diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py
index 00441690b47f..1b4cdb1c5831 100644
--- a/homeassistant/components/mqtt_room/sensor.py
+++ b/homeassistant/components/mqtt_room/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import dt, slugify
+from homeassistant.util import dt as dt_util, slugify
_LOGGER = logging.getLogger(__name__)
@@ -122,7 +122,7 @@ class MQTTRoomSensor(SensorEntity):
"""Update the sensor state."""
self._state = room
self._distance = distance
- self._updated = dt.utcnow()
+ self._updated = dt_util.utcnow()
self.async_write_ha_state()
@@ -144,7 +144,7 @@ class MQTTRoomSensor(SensorEntity):
# device is in the same room OR
# device is closer to another room OR
# last update from other room was too long ago
- timediff = dt.utcnow() - self._updated
+ timediff = dt_util.utcnow() - self._updated
if (
device.get(ATTR_ROOM) == self._state
or device.get(ATTR_DISTANCE) < self._distance
@@ -174,7 +174,7 @@ class MQTTRoomSensor(SensorEntity):
if (
self._updated
and self._consider_home
- and dt.utcnow() - self._updated > self._consider_home
+ and dt_util.utcnow() - self._updated > self._consider_home
):
self._state = STATE_NOT_HOME
diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py
index aa4c2c628b4c..328368258765 100644
--- a/homeassistant/components/mqtt_statestream/__init__.py
+++ b/homeassistant/components/mqtt_statestream/__init__.py
@@ -41,6 +41,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the MQTT state feed."""
+ # 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 False
+
conf: ConfigType = config[DOMAIN]
publish_filter = convert_include_exclude_filter(conf)
base_topic: str = conf[CONF_BASE_TOPIC]
diff --git a/homeassistant/components/my/__init__.py b/homeassistant/components/my/__init__.py
index b547662d1889..d699e42e1050 100644
--- a/homeassistant/components/my/__init__.py
+++ b/homeassistant/components/my/__init__.py
@@ -1,11 +1,14 @@
"""Support for my.home-assistant.io redirect service."""
from homeassistant.components import frontend
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "my"
URL_PATH = "_my_redirect"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register hidden _my_redirect panel."""
diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json
index 86adf0d48b5e..9b8731f0701b 100644
--- a/homeassistant/components/mycroft/manifest.json
+++ b/homeassistant/components/mycroft/manifest.json
@@ -2,6 +2,7 @@
"domain": "mycroft",
"name": "Mycroft",
"codeowners": [],
+ "disabled": "Dependencies not compatible with the new pip resolver",
"documentation": "https://www.home-assistant.io/integrations/mycroft",
"iot_class": "local_push",
"loggers": ["mycroftapi"],
diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py
index a9dd82caef1a..172a01017c46 100644
--- a/homeassistant/components/mycroft/notify.py
+++ b/homeassistant/components/mycroft/notify.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
-from mycroftapi import MycroftAPI
+from mycroftapi import MycroftAPI # pylint: disable=import-error
from homeassistant.components.notify import BaseNotificationService
from homeassistant.core import HomeAssistant
diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py
index 26ce5b115671..e01cebb818d4 100644
--- a/homeassistant/components/mystrom/light.py
+++ b/homeassistant/components/mystrom/light.py
@@ -31,8 +31,6 @@ DEFAULT_NAME = "myStrom bulb"
EFFECT_RAINBOW = "rainbow"
EFFECT_SUNRISE = "sunrise"
-MYSTROM_EFFECT_LIST = [EFFECT_RAINBOW, EFFECT_SUNRISE]
-
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
@@ -74,52 +72,15 @@ class MyStromLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
_attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.FLASH
+ _attr_effect_list = [EFFECT_RAINBOW, EFFECT_SUNRISE]
def __init__(self, bulb, name, mac):
"""Initialize the light."""
self._bulb = bulb
- self._name = name
- self._state = None
- self._available = False
- self._brightness = 0
- self._color_h = 0
- self._color_s = 0
- self._mac = mac
-
- @property
- def name(self):
- """Return the display name of this light."""
- return self._name
-
- @property
- def unique_id(self):
- """Return a unique ID."""
- return self._mac
-
- @property
- def brightness(self):
- """Return the brightness of the light."""
- return self._brightness
-
- @property
- def hs_color(self):
- """Return the color of the light."""
- return self._color_h, self._color_s
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._available
-
- @property
- def effect_list(self):
- """Return the list of supported effects."""
- return MYSTROM_EFFECT_LIST
-
- @property
- def is_on(self):
- """Return true if light is on."""
- return self._state
+ self._attr_name = name
+ self._attr_available = False
+ self._attr_unique_id = mac
+ self._attr_hs_color = 0, 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
@@ -130,7 +91,10 @@ class MyStromLight(LightEntity):
color_h, color_s = kwargs[ATTR_HS_COLOR]
elif ATTR_BRIGHTNESS in kwargs:
# Brightness update, keep color
- color_h, color_s = self._color_h, self._color_s
+ if self.hs_color is not None:
+ color_h, color_s = self.hs_color
+ else:
+ color_h, color_s = 0, 0 # Back to white
else:
color_h, color_s = 0, 0 # Back to white
@@ -159,7 +123,7 @@ class MyStromLight(LightEntity):
"""Fetch new state data for this light."""
try:
await self._bulb.get_state()
- self._state = self._bulb.state
+ self._attr_is_on = self._bulb.state
colors = self._bulb.color
try:
@@ -168,11 +132,10 @@ class MyStromLight(LightEntity):
color_s, color_v = colors.split(";")
color_h = 0
- self._color_h = int(color_h)
- self._color_s = int(color_s)
- self._brightness = int(color_v) * 255 / 100
+ self._attr_hs_color = int(color_h), int(color_s)
+ self._attr_brightness = int(int(color_v) * 255 / 100)
- self._available = True
+ self._attr_available = True
except MyStromConnectionError:
_LOGGER.warning("No route to myStrom bulb")
- self._available = False
+ self._attr_available = False
diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json
index d6f551451d47..7659b1d8025e 100644
--- a/homeassistant/components/mystrom/manifest.json
+++ b/homeassistant/components/mystrom/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mystrom",
"iot_class": "local_polling",
"loggers": ["pymystrom"],
- "requirements": ["python-mystrom==1.1.2"]
+ "requirements": ["python-mystrom==2.2.0"]
}
diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py
index 7bce3000424c..3d073693b1ea 100644
--- a/homeassistant/components/mystrom/switch.py
+++ b/homeassistant/components/mystrom/switch.py
@@ -53,30 +53,9 @@ class MyStromSwitch(SwitchEntity):
def __init__(self, plug, name):
"""Initialize the myStrom switch/plug."""
- self._name = name
self.plug = plug
- self._available = True
- self.relay = None
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return bool(self.relay)
-
- @property
- def unique_id(self):
- """Return a unique ID."""
- return self.plug._mac # pylint: disable=protected-access
-
- @property
- def available(self):
- """Could the device be accessed during the last update call."""
- return self._available
+ self._attr_name = name
+ self._attr_unique_id = self.plug.mac
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
@@ -96,9 +75,9 @@ class MyStromSwitch(SwitchEntity):
"""Get the latest data from the device and update the data."""
try:
await self.plug.get_state()
- self.relay = self.plug.relay
- self._available = True
+ self._attr_is_on = self.plug.relay
+ self._attr_available = True
except MyStromConnectionError:
- if self._available:
- self._available = False
+ if self.available:
+ self._attr_available = False
_LOGGER.error("No route to myStrom plug")
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index 2c7abe6f6c49..092e8ea08d63 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -22,10 +22,6 @@ from google_nest_sdm.exceptions import (
import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_READ
-from homeassistant.components.application_credentials import (
- ClientCredential,
- async_import_client_credential,
-)
from homeassistant.components.camera import Image, img_util
from homeassistant.components.http import KEY_HASS_USER
from homeassistant.components.http.view import HomeAssistantView
@@ -52,11 +48,6 @@ from homeassistant.helpers import (
entity_registry as er,
)
from homeassistant.helpers.entity_registry import async_entries_for_device
-from homeassistant.helpers.issue_registry import (
- IssueSeverity,
- async_create_issue,
- async_delete_issue,
-)
from homeassistant.helpers.typing import ConfigType
from . import api, config_flow
@@ -69,8 +60,6 @@ from .const import (
DATA_SDM,
DATA_SUBSCRIBER,
DOMAIN,
- INSTALLED_AUTH_DOMAIN,
- WEB_AUTH_DOMAIN,
)
from .events import EVENT_NAME_MAP, NEST_EVENT
from .legacy import async_setup_legacy, async_setup_legacy_entry
@@ -128,9 +117,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if DOMAIN not in config:
return True # ConfigMode.SDM_APPLICATION_CREDENTIALS
- # Note that configuration.yaml deprecation warnings are handled in the
- # config entry since we don't know what type of credentials we have and
- # whether or not they can be imported.
hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN]
config_mode = config_flow.get_config_mode(hass)
@@ -185,15 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY:
return await async_setup_legacy_entry(hass, entry)
- if config_mode == config_flow.ConfigMode.SDM:
- await async_import_config(hass, entry)
- elif entry.unique_id != entry.data[CONF_PROJECT_ID]:
+ if entry.unique_id != entry.data[CONF_PROJECT_ID]:
hass.config_entries.async_update_entry(
entry, unique_id=entry.data[CONF_PROJECT_ID]
)
- async_delete_issue(hass, DOMAIN, "removed_app_auth")
-
subscriber = await api.new_subscriber(hass, entry)
if not subscriber:
return False
@@ -239,71 +221,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Attempt to import configuration.yaml settings."""
- config = hass.data[DOMAIN][DATA_NEST_CONFIG]
- new_data = {
- CONF_PROJECT_ID: config[CONF_PROJECT_ID],
- **entry.data,
- }
- if CONF_SUBSCRIBER_ID not in entry.data:
- if CONF_SUBSCRIBER_ID not in config:
- raise ValueError("Configuration option 'subscriber_id' missing")
- new_data.update(
- {
- CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID],
- # Don't delete user managed subscriber
- CONF_SUBSCRIBER_ID_IMPORTED: True,
- }
- )
- hass.config_entries.async_update_entry(
- entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID]
- )
-
- if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
- # App Auth credentials have been deprecated and must be re-created
- # by the user in the config flow
- async_create_issue(
- hass,
- DOMAIN,
- "removed_app_auth",
- is_fixable=False,
- severity=IssueSeverity.ERROR,
- translation_key="removed_app_auth",
- translation_placeholders={
- "more_info_url": (
- "https://www.home-assistant.io/more-info/nest-auth-deprecation"
- ),
- "documentation_url": "https://www.home-assistant.io/integrations/nest/",
- },
- )
- raise ConfigEntryAuthFailed(
- "Google has deprecated App Auth credentials, and the integration "
- "must be reconfigured in the UI to restore access to Nest Devices."
- )
-
- if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN:
- await async_import_client_credential(
- hass,
- DOMAIN,
- ClientCredential(
- config[CONF_CLIENT_ID],
- config[CONF_CLIENT_SECRET],
- ),
- WEB_AUTH_DOMAIN,
- )
-
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2022.10.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- )
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if DATA_SDM not in entry.data:
diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py
index a4642c38e846..d20057f4e28e 100644
--- a/homeassistant/components/nest/config_flow.py
+++ b/homeassistant/components/nest/config_flow.py
@@ -43,7 +43,6 @@ from .const import (
DATA_NEST_CONFIG,
DATA_SDM,
DOMAIN,
- INSTALLED_AUTH_DOMAIN,
OAUTH2_AUTHORIZE,
SDM_SCOPES,
)
@@ -64,10 +63,6 @@ PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapi
# URLs for Configure Device Access Project step
DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/"
-# URLs for App Auth deprecation and upgrade
-UPGRADE_MORE_INFO_URL = (
- "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials"
-)
DEVICE_ACCESS_CONSOLE_EDIT_URL = (
"https://console.nest.google.com/device-access/project/{project_id}/information"
)
@@ -161,7 +156,6 @@ class NestFlowHandler(
def __init__(self) -> None:
"""Initialize NestFlowHandler."""
super().__init__()
- self._upgrade = False
self._data: dict[str, Any] = {DATA_SDM: {}}
# Possible name to use for config entry based on the Google Home name
self._structure_config_title: str | None = None
@@ -233,38 +227,8 @@ class NestFlowHandler(
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
- if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
- # The config entry points to an auth mechanism that no longer works and the
- # user needs to take action in the google cloud console to resolve. First
- # prompt to create app creds, then later ensure they've updated the device
- # access console.
- self._upgrade = True
- implementations = await config_entry_oauth2_flow.async_get_implementations(
- self.hass, self.DOMAIN
- )
- if not implementations:
- return await self.async_step_auth_upgrade()
return await self.async_step_user()
- async def async_step_auth_upgrade(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Give instructions for upgrade of deprecated app auth."""
- assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
- if user_input is None:
- return self.async_show_form(
- step_id="auth_upgrade",
- description_placeholders={
- "more_info_url": UPGRADE_MORE_INFO_URL,
- },
- )
- # Abort this flow and ask the user for application credentials. The frontend
- # will restart a new config flow after the user finishes so schedule a new
- # re-auth config flow for the same entry so the user may resume.
- if reauth_entry := self._async_reauth_entry():
- self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass)
- return self.async_abort(reason="missing_credentials")
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -358,39 +322,6 @@ class NestFlowHandler(
errors=errors,
)
- async def async_step_auth(
- self, user_input: dict[str, Any] | None = None
- ) -> FlowResult:
- """Verify any last pre-requisites before sending user through OAuth flow."""
- if user_input is None and self._upgrade:
- # During app auth upgrade we need the user to update their device
- # access project before we redirect to the authentication flow.
- return await self.async_step_device_project_upgrade()
- return await super().async_step_auth(user_input)
-
- async def async_step_device_project_upgrade(
- self, user_input: dict | None = None
- ) -> FlowResult:
- """Update the device access project."""
- if user_input is not None:
- # Resume OAuth2 redirects
- return await super().async_step_auth()
- if not isinstance(
- self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation
- ):
- raise TypeError(f"Unexpected OAuth implementation: {self.flow_impl}")
- client_id = self.flow_impl.client_id
- return self.async_show_form(
- step_id="device_project_upgrade",
- description_placeholders={
- "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format(
- project_id=self._data[CONF_PROJECT_ID]
- ),
- "more_info_url": UPGRADE_MORE_INFO_URL,
- "client_id": client_id,
- },
- )
-
async def async_step_pubsub(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py
index 3c3a10a61bfe..88d046fb62be 100644
--- a/homeassistant/components/nest/legacy/__init__.py
+++ b/homeassistant/components/nest/legacy/__init__.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.entity import DeviceInfo, Entity
@@ -108,6 +108,19 @@ async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool:
if DOMAIN not in config:
return True
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "legacy_nest_deprecated",
+ breaks_in_ha_version="2023.8.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="legacy_nest_deprecated",
+ translation_placeholders={
+ "documentation_url": "https://www.home-assistant.io/integrations/nest/",
+ },
+ )
+
conf = config[DOMAIN]
local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET])
diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json
index 4176ad1e2279..2578437acf47 100644
--- a/homeassistant/components/nest/strings.json
+++ b/homeassistant/components/nest/strings.json
@@ -4,14 +4,6 @@
},
"config": {
"step": {
- "auth_upgrade": {
- "title": "Nest: App Auth Deprecation",
- "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices."
- },
- "device_project_upgrade": {
- "title": "Nest: Update Device Access Project",
- "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`"
- },
"create_cloud_project": {
"title": "Nest: Create and configure Cloud Project",
"description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up."
@@ -90,13 +82,9 @@
}
},
"issues": {
- "deprecated_yaml": {
- "title": "The Nest YAML configuration is being removed",
- "description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- },
- "removed_app_auth": {
- "title": "Nest Authentication Credentials must be updated",
- "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information."
+ "legacy_nest_deprecated": {
+ "title": "Legacy Works With Nest is being removed",
+ "description": "Legacy Works With Nest is being removed from Home Assistant.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices."
}
},
"entity": {
diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json
index 2d7604765c43..99410ce033d3 100644
--- a/homeassistant/components/netdata/manifest.json
+++ b/homeassistant/components/netdata/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/netdata",
"iot_class": "local_polling",
"loggers": ["netdata"],
- "requirements": ["netdata==1.0.1"]
+ "requirements": ["netdata==1.1.0"]
}
diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py
index 6606604ac90c..1ab7a48e1b32 100644
--- a/homeassistant/components/netdata/sensor.py
+++ b/homeassistant/components/netdata/sensor.py
@@ -66,7 +66,7 @@ async def async_setup_platform(
port = config[CONF_PORT]
resources = config[CONF_RESOURCES]
- netdata = NetdataData(Netdata(host, port=port))
+ netdata = NetdataData(Netdata(host, port=port, timeout=20.0))
await netdata.async_update()
if netdata.api.metrics is None:
diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py
index 0ab3dd07edf6..ed9ee49a0de8 100644
--- a/homeassistant/components/netgear_lte/__init__.py
+++ b/homeassistant/components/netgear_lte/__init__.py
@@ -1,8 +1,6 @@
"""Support for Netgear LTE modems."""
import asyncio
from datetime import timedelta
-import logging
-from typing import Final
import aiohttp
import attr
@@ -18,48 +16,32 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-from homeassistant.helpers.dispatcher import (
- async_dispatcher_connect,
- async_dispatcher_send,
-)
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from . import sensor_types
-
-_LOGGER = logging.getLogger(__name__)
+from .const import (
+ ATTR_FROM,
+ ATTR_HOST,
+ ATTR_MESSAGE,
+ ATTR_SMS_ID,
+ CONF_BINARY_SENSOR,
+ CONF_NOTIFY,
+ CONF_SENSOR,
+ DISPATCHER_NETGEAR_LTE,
+ DOMAIN,
+ LOGGER,
+)
+from .services import async_setup_services
SCAN_INTERVAL = timedelta(seconds=10)
-DISPATCHER_NETGEAR_LTE = "netgear_lte_update"
-
-CONF_NOTIFY: Final = "notify"
-CONF_BINARY_SENSOR: Final = "binary_sensor"
-CONF_SENSOR: Final = "sensor"
-
-DOMAIN = "netgear_lte"
-DATA_KEY = "netgear_lte"
EVENT_SMS = "netgear_lte_sms"
-SERVICE_DELETE_SMS = "delete_sms"
-SERVICE_SET_OPTION = "set_option"
-SERVICE_CONNECT_LTE = "connect_lte"
-SERVICE_DISCONNECT_LTE = "disconnect_lte"
-
-ATTR_HOST = "host"
-ATTR_SMS_ID = "sms_id"
-ATTR_FROM = "from"
-ATTR_MESSAGE = "message"
-ATTR_FAILOVER = "failover"
-ATTR_AUTOCONNECT = "autoconnect"
-
-FAILOVER_MODES = ["auto", "wire", "mobile"]
-AUTOCONNECT_MODES = ["never", "home", "always"]
-
NOTIFY_SCHEMA = vol.Schema(
{
@@ -108,28 +90,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-DELETE_SMS_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_HOST): cv.string,
- vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]),
- }
-)
-
-SET_OPTION_SCHEMA = vol.Schema(
- vol.All(
- cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT),
- {
- vol.Optional(ATTR_HOST): cv.string,
- vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES),
- vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES),
- },
- )
-)
-
-CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string})
-
-DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string})
-
@attr.s
class ModemData:
@@ -148,11 +108,11 @@ class ModemData:
try:
self.data = await self.modem.information()
if not self.connected:
- _LOGGER.warning("Connected to %s", self.host)
+ LOGGER.warning("Connected to %s", self.host)
self.connected = True
except eternalegypt.Error:
if self.connected:
- _LOGGER.warning("Lost connection to %s", self.host)
+ LOGGER.warning("Lost connection to %s", self.host)
self.connected = False
self.data = None
@@ -177,46 +137,13 @@ class LTEData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Netgear LTE component."""
- if DATA_KEY not in hass.data:
+ if DOMAIN not in hass.data:
websession = async_create_clientsession(
hass, cookie_jar=aiohttp.CookieJar(unsafe=True)
)
- hass.data[DATA_KEY] = LTEData(websession)
+ hass.data[DOMAIN] = LTEData(websession)
- async def service_handler(service: ServiceCall) -> None:
- """Apply a service."""
- host = service.data.get(ATTR_HOST)
- conf = {CONF_HOST: host}
- modem_data = hass.data[DATA_KEY].get_modem_data(conf)
-
- if not modem_data:
- _LOGGER.error("%s: host %s unavailable", service.service, host)
- return
-
- if service.service == SERVICE_DELETE_SMS:
- for sms_id in service.data[ATTR_SMS_ID]:
- await modem_data.modem.delete_sms(sms_id)
- elif service.service == SERVICE_SET_OPTION:
- if failover := service.data.get(ATTR_FAILOVER):
- await modem_data.modem.set_failover_mode(failover)
- if autoconnect := service.data.get(ATTR_AUTOCONNECT):
- await modem_data.modem.set_autoconnect_mode(autoconnect)
- elif service.service == SERVICE_CONNECT_LTE:
- await modem_data.modem.connect_lte()
- elif service.service == SERVICE_DISCONNECT_LTE:
- await modem_data.modem.disconnect_lte()
-
- service_schemas = {
- SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA,
- SERVICE_SET_OPTION: SET_OPTION_SCHEMA,
- SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA,
- SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA,
- }
-
- for service, schema in service_schemas.items():
- hass.services.async_register(
- DOMAIN, service, service_handler, schema=schema
- )
+ await async_setup_services(hass)
netgear_lte_config = config[DOMAIN]
@@ -272,7 +199,7 @@ async def _setup_lte(hass, lte_config):
host = lte_config[CONF_HOST]
password = lte_config[CONF_PASSWORD]
- websession = hass.data[DATA_KEY].websession
+ websession = hass.data[DOMAIN].websession
modem = eternalegypt.Modem(hostname=host, websession=websession)
modem_data = ModemData(hass, host, modem)
@@ -308,7 +235,7 @@ async def _login(hass, modem_data, password):
await modem_data.modem.add_sms_listener(fire_sms_event)
await modem_data.async_update()
- hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data
+ hass.data[DOMAIN].modem_data[modem_data.host] = modem_data
async def _update(now):
"""Periodic update."""
@@ -320,7 +247,7 @@ async def _login(hass, modem_data, password):
"""Clean up resources."""
update_unsub()
await modem_data.modem.logout()
- del hass.data[DATA_KEY].modem_data[modem_data.host]
+ del hass.data[DOMAIN].modem_data[modem_data.host]
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
@@ -328,7 +255,7 @@ async def _login(hass, modem_data, password):
async def _retry_login(hass, modem_data, password):
"""Sleep and retry setup."""
- _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host)
+ LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host)
modem_data.connected = False
delay = 15
@@ -340,50 +267,3 @@ async def _retry_login(hass, modem_data, password):
await _login(hass, modem_data, password)
except eternalegypt.Error:
delay = min(2 * delay, 300)
-
-
-@attr.s
-class LTEEntity(Entity):
- """Base LTE entity."""
-
- modem_data = attr.ib()
- sensor_type = attr.ib()
-
- _unique_id = attr.ib(init=False)
-
- @_unique_id.default
- def _init_unique_id(self):
- """Register unique_id while we know data is valid."""
- return f"{self.sensor_type}_{self.modem_data.data.serial_number}"
-
- async def async_added_to_hass(self):
- """Register callback."""
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state
- )
- )
-
- async def async_update(self):
- """Force update of state."""
- await self.modem_data.async_update()
-
- @property
- def should_poll(self):
- """Return that the sensor should not be polled."""
- return False
-
- @property
- def available(self):
- """Return the availability of the sensor."""
- return self.modem_data.data is not None
-
- @property
- def unique_id(self):
- """Return a unique ID like 'usage_5TG365AB0078V'."""
- return self._unique_id
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return f"Netgear LTE {self.sensor_type}"
diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py
index 0e7cd14bea6e..add59096024c 100644
--- a/homeassistant/components/netgear_lte/binary_sensor.py
+++ b/homeassistant/components/netgear_lte/binary_sensor.py
@@ -2,12 +2,14 @@
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import CONF_BINARY_SENSOR, CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity
+from .const import CONF_BINARY_SENSOR, DOMAIN
+from .entity import LTEEntity
from .sensor_types import BINARY_SENSOR_CLASSES
@@ -21,7 +23,7 @@ async def async_setup_platform(
if discovery_info is None:
return
- modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info)
+ modem_data = hass.data[DOMAIN].get_modem_data(discovery_info)
if not modem_data or not modem_data.data:
raise PlatformNotReady
diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py
new file mode 100644
index 000000000000..12c8f06b695c
--- /dev/null
+++ b/homeassistant/components/netgear_lte/const.py
@@ -0,0 +1,22 @@
+"""Constants for the Netgear LTE integration."""
+import logging
+from typing import Final
+
+ATTR_HOST = "host"
+ATTR_SMS_ID = "sms_id"
+ATTR_FROM = "from"
+ATTR_MESSAGE = "message"
+ATTR_FAILOVER = "failover"
+ATTR_AUTOCONNECT = "autoconnect"
+AUTOCONNECT_MODES = ["never", "home", "always"]
+
+CONF_BINARY_SENSOR: Final = "binary_sensor"
+CONF_NOTIFY: Final = "notify"
+CONF_SENSOR: Final = "sensor"
+
+DISPATCHER_NETGEAR_LTE = "netgear_lte_update"
+DOMAIN: Final = "netgear_lte"
+
+FAILOVER_MODES = ["auto", "wire", "mobile"]
+
+LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py
new file mode 100644
index 000000000000..33e0aaab749a
--- /dev/null
+++ b/homeassistant/components/netgear_lte/entity.py
@@ -0,0 +1,41 @@
+"""Entity representing a Netgear LTE entity."""
+
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import ModemData
+from .const import DISPATCHER_NETGEAR_LTE
+
+
+class LTEEntity(Entity):
+ """Base LTE entity."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ modem_data: ModemData,
+ sensor_type: str,
+ ) -> None:
+ """Initialize a Netgear LTE entity."""
+ self.modem_data = modem_data
+ self.sensor_type = sensor_type
+ self._attr_name = f"Netgear LTE {sensor_type}"
+ self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}"
+
+ async def async_added_to_hass(self) -> None:
+ """Register callback."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state
+ )
+ )
+
+ async def async_update(self) -> None:
+ """Force update of state."""
+ await self.modem_data.async_update()
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the sensor."""
+ return self.modem_data.data is not None
diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py
index 52ce3a85dcb8..c21b56799eb7 100644
--- a/homeassistant/components/netgear_lte/notify.py
+++ b/homeassistant/components/netgear_lte/notify.py
@@ -1,18 +1,15 @@
"""Support for Netgear LTE notifications."""
from __future__ import annotations
-import logging
-
import attr
import eternalegypt
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
+from homeassistant.const import CONF_RECIPIENT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import CONF_NOTIFY, CONF_RECIPIENT, DATA_KEY
-
-_LOGGER = logging.getLogger(__name__)
+from .const import CONF_NOTIFY, DOMAIN, LOGGER
async def async_get_service(
@@ -37,14 +34,14 @@ class NetgearNotifyService(BaseNotificationService):
async def async_send_message(self, message="", **kwargs):
"""Send a message to a user."""
- modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config)
+ modem_data = self.hass.data[DOMAIN].get_modem_data(self.config)
if not modem_data:
- _LOGGER.error("Modem not ready")
+ LOGGER.error("Modem not ready")
return
targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT])
if not targets:
- _LOGGER.warning("No recipients")
+ LOGGER.warning("No recipients")
return
if not message:
@@ -54,4 +51,4 @@ class NetgearNotifyService(BaseNotificationService):
try:
await modem_data.modem.sms(target, message)
except eternalegypt.Error:
- _LOGGER.error("Unable to send to %s", target)
+ LOGGER.error("Unable to send to %s", target)
diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py
index 49942306da26..4ca127e57241 100644
--- a/homeassistant/components/netgear_lte/sensor.py
+++ b/homeassistant/components/netgear_lte/sensor.py
@@ -2,12 +2,14 @@
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import CONF_MONITORED_CONDITIONS, CONF_SENSOR, DATA_KEY, LTEEntity
+from .const import CONF_SENSOR, DOMAIN
+from .entity import LTEEntity
from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE
@@ -21,7 +23,7 @@ async def async_setup_platform(
if discovery_info is None:
return
- modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info)
+ modem_data = hass.data[DOMAIN].get_modem_data(discovery_info)
if not modem_data or not modem_data.data:
raise PlatformNotReady
diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py
new file mode 100644
index 000000000000..2ea98896791b
--- /dev/null
+++ b/homeassistant/components/netgear_lte/services.py
@@ -0,0 +1,86 @@
+"""Services for the Netgear LTE integration."""
+from typing import TYPE_CHECKING
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+
+from .const import (
+ ATTR_AUTOCONNECT,
+ ATTR_FAILOVER,
+ ATTR_HOST,
+ ATTR_SMS_ID,
+ AUTOCONNECT_MODES,
+ DOMAIN,
+ FAILOVER_MODES,
+ LOGGER,
+)
+
+if TYPE_CHECKING:
+ from . import LTEData, ModemData
+
+SERVICE_DELETE_SMS = "delete_sms"
+SERVICE_SET_OPTION = "set_option"
+SERVICE_CONNECT_LTE = "connect_lte"
+SERVICE_DISCONNECT_LTE = "disconnect_lte"
+
+DELETE_SMS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]),
+ }
+)
+
+SET_OPTION_SCHEMA = vol.Schema(
+ vol.All(
+ cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT),
+ {
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES),
+ vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES),
+ },
+ )
+)
+
+CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string})
+
+DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string})
+
+
+async def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up services for Netgear LTE integration."""
+
+ async def service_handler(call: ServiceCall) -> None:
+ """Apply a service."""
+ host = call.data.get(ATTR_HOST)
+ data: LTEData = hass.data[DOMAIN]
+ modem_data: ModemData = data.get_modem_data({CONF_HOST: host})
+
+ if not modem_data:
+ LOGGER.error("%s: host %s unavailable", call.service, host)
+ return
+
+ if call.service == SERVICE_DELETE_SMS:
+ for sms_id in call.data[ATTR_SMS_ID]:
+ await modem_data.modem.delete_sms(sms_id)
+ elif call.service == SERVICE_SET_OPTION:
+ if failover := call.data.get(ATTR_FAILOVER):
+ await modem_data.modem.set_failover_mode(failover)
+ if autoconnect := call.data.get(ATTR_AUTOCONNECT):
+ await modem_data.modem.set_autoconnect_mode(autoconnect)
+ elif call.service == SERVICE_CONNECT_LTE:
+ await modem_data.modem.connect_lte()
+ elif call.service == SERVICE_DISCONNECT_LTE:
+ await modem_data.modem.disconnect_lte()
+
+ service_schemas = {
+ SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA,
+ SERVICE_SET_OPTION: SET_OPTION_SCHEMA,
+ SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA,
+ SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA,
+ }
+
+ for service, schema in service_schemas.items():
+ hass.services.async_register(DOMAIN, service, service_handler, schema=schema)
diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py
index a57334d2531f..30ff2280408d 100644
--- a/homeassistant/components/network/__init__.py
+++ b/homeassistant/components/network/__init__.py
@@ -6,11 +6,13 @@ import logging
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.loader import bind_hass
from . import util
from .const import (
+ DOMAIN,
IPV4_BROADCAST_ADDR,
LOOPBACK_TARGET_IP,
MDNS_TARGET_IP,
@@ -21,6 +23,8 @@ from .network import Network, async_get_network
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@bind_hass
async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json
index f7e0bea2422b..a76da88914d6 100644
--- a/homeassistant/components/network/manifest.json
+++ b/homeassistant/components/network/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["ifaddr==0.1.7"]
+ "requirements": ["ifaddr==0.2.0"]
}
diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py
index d3fbc824489c..4428578f8f90 100644
--- a/homeassistant/components/network/models.py
+++ b/homeassistant/components/network/models.py
@@ -24,7 +24,7 @@ class Adapter(TypedDict):
"""Configured network adapters."""
name: str
- index: int
+ index: int | None
enabled: bool
auto: bool
default: bool
diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py
index 6f204b053976..2fb0690684c3 100644
--- a/homeassistant/components/network/util.py
+++ b/homeassistant/components/network/util.py
@@ -97,6 +97,7 @@ def _ifaddr_adapter_to_ha(
ip_addr = ip_address(ip_config.ip[0])
ip_v6s.append(_ip_v6_from_adapter(ip_config))
else:
+ assert not isinstance(ip_config.ip, tuple)
ip_addr = ip_address(ip_config.ip)
ip_v4s.append(_ip_v4_from_adapter(ip_config))
@@ -117,6 +118,7 @@ def _ifaddr_adapter_to_ha(
def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress:
+ assert isinstance(ip_config.ip, tuple)
return {
"address": ip_config.ip[0],
"flowinfo": ip_config.ip[1],
@@ -126,6 +128,7 @@ def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress:
def _ip_v4_from_adapter(ip_config: ifaddr.IP) -> IPv4ConfiguredAddress:
+ assert not isinstance(ip_config.ip, tuple)
return {
"address": ip_config.ip,
"network_prefix": ip_config.network_prefix,
diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py
index b221f440ff84..0644de58ee7b 100644
--- a/homeassistant/components/nexia/__init__.py
+++ b/homeassistant/components/nexia/__init__.py
@@ -56,6 +56,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
raise ConfigEntryNotReady(f"Error from Nexia service: {http_ex}") from http_ex
+ except aiohttp.ClientOSError as os_error:
+ raise ConfigEntryNotReady(
+ f"Error connecting to Nexia service: {os_error}"
+ ) from os_error
coordinator = NexiaDataUpdateCoordinator(hass, nexia_home)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py
new file mode 100644
index 000000000000..9d9d0a5262fd
--- /dev/null
+++ b/homeassistant/components/nextbus/const.py
@@ -0,0 +1,6 @@
+"""NextBus Constants."""
+DOMAIN = "nextbus"
+
+CONF_AGENCY = "agency"
+CONF_ROUTE = "route"
+CONF_STOP = "stop"
diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json
index f475c94ed47f..b77ffa86f036 100644
--- a/homeassistant/components/nextbus/manifest.json
+++ b/homeassistant/components/nextbus/manifest.json
@@ -3,7 +3,7 @@
"name": "NextBus",
"codeowners": ["@vividboarder"],
"documentation": "https://www.home-assistant.io/integrations/nextbus",
- "iot_class": "local_polling",
+ "iot_class": "cloud_polling",
"loggers": ["py_nextbus"],
"requirements": ["py_nextbusnext==0.1.5"]
}
diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py
index 4f24a7aa7f3b..02f5d8695ca1 100644
--- a/homeassistant/components/nextbus/sensor.py
+++ b/homeassistant/components/nextbus/sensor.py
@@ -19,14 +19,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import utc_from_timestamp
+from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP
+from .util import listify, maybe_first
+
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "nextbus"
-
-CONF_AGENCY = "agency"
-CONF_ROUTE = "route"
-CONF_STOP = "stop"
-
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_AGENCY): cv.string,
@@ -37,29 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def listify(maybe_list):
- """Return list version of whatever value is passed in.
-
- This is used to provide a consistent way of interacting with the JSON
- results from the API. There are several attributes that will either missing
- if there are no values, a single dictionary if there is only one value, and
- a list if there are multiple.
- """
- if maybe_list is None:
- return []
- if isinstance(maybe_list, list):
- return maybe_list
- return [maybe_list]
-
-
-def maybe_first(maybe_list):
- """Return the first item out of a list or returns back the input."""
- if isinstance(maybe_list, list) and maybe_list:
- return maybe_list[0]
-
- return maybe_list
-
-
def validate_value(value_name, value, value_list):
"""Validate tag value is in the list of items and logs error if not."""
valid_values = {v["tag"]: v["title"] for v in value_list}
diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py
new file mode 100644
index 000000000000..c753c452546a
--- /dev/null
+++ b/homeassistant/components/nextbus/util.py
@@ -0,0 +1,25 @@
+"""Utils for NextBus integration module."""
+from typing import Any
+
+
+def listify(maybe_list: Any) -> list[Any]:
+ """Return list version of whatever value is passed in.
+
+ This is used to provide a consistent way of interacting with the JSON
+ results from the API. There are several attributes that will either missing
+ if there are no values, a single dictionary if there is only one value, and
+ a list if there are multiple.
+ """
+ if maybe_list is None:
+ return []
+ if isinstance(maybe_list, list):
+ return maybe_list
+ return [maybe_list]
+
+
+def maybe_first(maybe_list: list[Any]) -> Any:
+ """Return the first item out of a list or returns back the input."""
+ if isinstance(maybe_list, list) and maybe_list:
+ return maybe_list[0]
+
+ return maybe_list
diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py
index 65829f713ef5..8e2f39cf9b5f 100644
--- a/homeassistant/components/nextcloud/__init__.py
+++ b/homeassistant/components/nextcloud/__init__.py
@@ -1,5 +1,4 @@
"""The Nextcloud integration."""
-import logging
from nextcloudmonitor import (
NextcloudMonitor,
@@ -7,12 +6,10 @@ from nextcloudmonitor import (
NextcloudMonitorConnectionError,
NextcloudMonitorRequestError,
)
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
- CONF_SCAN_INTERVAL,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
@@ -21,58 +18,13 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import DOMAIN
from .coordinator import NextcloudDataUpdateCoordinator
-_LOGGER = logging.getLogger(__name__)
PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR)
-# Validate user configuration
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_URL): cv.url,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(
- CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
- ): cv.time_period,
- },
- )
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Nextcloud integration."""
- if DOMAIN in config:
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2023.6.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config[DOMAIN],
- )
- )
-
- return True
+CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py
index c5019603c09c..ec56307aad77 100644
--- a/homeassistant/components/nextcloud/config_flow.py
+++ b/homeassistant/components/nextcloud/config_flow.py
@@ -2,14 +2,12 @@
from __future__ import annotations
from collections.abc import Mapping
-import logging
from typing import Any
from nextcloudmonitor import (
NextcloudMonitor,
NextcloudMonitorAuthorizationError,
NextcloudMonitorConnectionError,
- NextcloudMonitorError,
NextcloudMonitorRequestError,
)
import voluptuous as vol
@@ -35,8 +33,6 @@ DATA_SCHEMA_REAUTH = vol.Schema(
}
)
-_LOGGER = logging.getLogger(__name__)
-
class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a Nextcloud config flow."""
@@ -54,25 +50,6 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN):
user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
- async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
- """Handle a flow initiated by configuration file."""
- self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)})
- try:
- await self.hass.async_add_executor_job(self._try_connect_nc, user_input)
- except NextcloudMonitorError:
- _LOGGER.error(
- "Connection error during import of yaml configuration, import aborted"
- )
- return self.async_abort(reason="connection_error_during_import")
- return await self.async_step_user(
- {
- CONF_URL: user_input[CONF_URL],
- CONF_PASSWORD: user_input[CONF_PASSWORD],
- CONF_USERNAME: user_input[CONF_USERNAME],
- CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
- }
- )
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json
index e068ae4041e5..bcb530ffd734 100644
--- a/homeassistant/components/nextcloud/strings.json
+++ b/homeassistant/components/nextcloud/strings.json
@@ -28,11 +28,5 @@
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}
- },
- "issues": {
- "deprecated_yaml": {
- "title": "The Nextcloud YAML configuration has been deprecated",
- "description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py
index 89aac6bed61d..b46102879c4a 100644
--- a/homeassistant/components/nibe_heatpump/__init__.py
+++ b/homeassistant/components/nibe_heatpump/__init__.py
@@ -54,6 +54,7 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.WATER_HEATER,
]
COIL_READ_RETRIES = 5
diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py
index dc6b4b18996b..0f16567671c6 100644
--- a/homeassistant/components/nibe_heatpump/const.py
+++ b/homeassistant/components/nibe_heatpump/const.py
@@ -17,4 +17,7 @@ CONF_MODBUS_UNIT = "modbus_unit"
VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED")
VALUES_PRIORITY_HEATING = (30, "HEAT")
VALUES_PRIORITY_COOLING = (60, "COOLING")
+VALUES_PRIORITY_HOT_WATER = (20, "HOT WATER")
+VALUES_TEMPORARY_LUX_INACTIVE = "OFF"
+VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE = "ONE TIME INCREASE"
VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF")
diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json
index 81c23437bbc0..f57a4511eec7 100644
--- a/homeassistant/components/nibe_heatpump/manifest.json
+++ b/homeassistant/components/nibe_heatpump/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling",
- "requirements": ["nibe==2.1.4"]
+ "requirements": ["nibe==2.2.0"]
}
diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py
new file mode 100644
index 000000000000..0c6063807764
--- /dev/null
+++ b/homeassistant/components/nibe_heatpump/water_heater.py
@@ -0,0 +1,185 @@
+"""The Nibe Heat Pump sensors."""
+from __future__ import annotations
+
+from nibe.coil import Coil
+from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup
+from nibe.exceptions import CoilNotFoundException
+
+from homeassistant.components.water_heater import (
+ STATE_HEAT_PUMP,
+ STATE_HIGH_DEMAND,
+ WaterHeaterEntity,
+ WaterHeaterEntityFeature,
+)
+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 homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import DOMAIN, LOGGER, Coordinator
+from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up platform."""
+
+ coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ def water_heaters():
+ for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items():
+ try:
+ yield WaterHeater(coordinator, key, group)
+ except CoilNotFoundException as exception:
+ LOGGER.debug("Skipping water heater: %r", exception)
+
+ async_add_entities(water_heaters())
+
+
+class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity):
+ """Sensor entity."""
+
+ _attr_entity_category = None
+ _attr_has_entity_name = True
+ _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE
+ _attr_max_temp = 35.0
+ _attr_min_temp = 5.0
+
+ def __init__(
+ self,
+ coordinator: Coordinator,
+ key: str,
+ desc: WaterHeaterCoilGroup,
+ ) -> None:
+ """Initialize entity."""
+
+ super().__init__(
+ coordinator,
+ {
+ desc.hot_water_load,
+ desc.hot_water_comfort_mode,
+ *set(desc.start_temperature.values()),
+ *set(desc.stop_temperature.values()),
+ desc.active_accessory,
+ desc.temporary_lux,
+ },
+ )
+ self._attr_entity_registry_enabled_default = desc.active_accessory is None
+ self._attr_available = False
+ self._attr_name = desc.name
+ self._attr_unique_id = f"{coordinator.unique_id}-{key}"
+ self._attr_device_info = coordinator.device_info
+
+ self._attr_current_operation = None
+ self._attr_target_temperature_high = None
+ self._attr_target_temperature_low = None
+ self._attr_operation_list = []
+ self._operation_mode_to_lux: dict[str, str] = {}
+
+ def _get(address: int) -> Coil:
+ return coordinator.heatpump.get_coil_by_address(address)
+
+ def _map(data: dict[str, int]) -> dict[str, Coil]:
+ return {key: _get(address) for key, address in data.items()}
+
+ self._coil_current = _get(desc.hot_water_load)
+ self._coil_start_temperature = _map(desc.start_temperature)
+ self._coil_stop_temperature = _map(desc.stop_temperature)
+ self._coil_temporary_lux: Coil | None = None
+ if desc.temporary_lux:
+ self._coil_temporary_lux = _get(desc.temporary_lux)
+ self._coil_active_accessory: Coil | None = None
+ if address := desc.active_accessory:
+ self._coil_active_accessory = _get(address)
+
+ self._coil_hot_water_comfort_mode = _get(desc.hot_water_comfort_mode)
+
+ def _add_lux_mode(temporary_lux: str, operation_mode: str) -> None:
+ assert self._attr_operation_list is not None
+ if (
+ not self._coil_temporary_lux
+ or not self._coil_temporary_lux.reverse_mappings
+ ):
+ return
+
+ if temporary_lux not in self._coil_temporary_lux.reverse_mappings:
+ return
+
+ self._attr_operation_list.append(operation_mode)
+ self._operation_mode_to_lux[operation_mode] = temporary_lux
+
+ _add_lux_mode(VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, STATE_HIGH_DEMAND)
+ _add_lux_mode(VALUES_TEMPORARY_LUX_INACTIVE, STATE_HEAT_PUMP)
+
+ self._attr_temperature_unit = self._coil_current.unit
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ if not self.coordinator.data:
+ return
+
+ def _get_float(coil: Coil | None) -> float | None:
+ if coil is None:
+ return None
+ return self.coordinator.get_coil_float(coil)
+
+ def _get_value(coil: Coil | None) -> int | str | float | None:
+ if coil is None:
+ return None
+ return self.coordinator.get_coil_value(coil)
+
+ self._attr_current_temperature = _get_float(self._coil_current)
+
+ if (mode := _get_value(self._coil_hot_water_comfort_mode)) and isinstance(
+ mode, str
+ ):
+ self._attr_target_temperature_low = _get_float(
+ self._coil_start_temperature.get(mode)
+ )
+ self._attr_target_temperature_high = _get_float(
+ self._coil_stop_temperature.get(mode)
+ )
+ else:
+ self._attr_target_temperature_low = None
+ self._attr_target_temperature_high = None
+
+ if (
+ _get_value(self._coil_temporary_lux)
+ == VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE
+ ):
+ self._attr_current_operation = STATE_HIGH_DEMAND
+ else:
+ self._attr_current_operation = STATE_HEAT_PUMP
+
+ super()._handle_coordinator_update()
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ if not self.coordinator.last_update_success:
+ return False
+
+ if not self._coil_active_accessory:
+ return True
+
+ if active_accessory := self.coordinator.get_coil_value(
+ self._coil_active_accessory
+ ):
+ return active_accessory == "ON"
+
+ return False
+
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
+ """Set new target operation mode."""
+ if not self._coil_temporary_lux:
+ raise HomeAssistantError("Not supported")
+
+ lux = self._operation_mode_to_lux.get(operation_mode)
+ if not lux:
+ raise ValueError(f"Unsupported operation mode {operation_mode}")
+
+ await self.coordinator.async_write_coil(self._coil_temporary_lux, lux)
diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py
index 7cb69dfb79a7..d1661dce0fa5 100644
--- a/homeassistant/components/nobo_hub/climate.py
+++ b/homeassistant/components/nobo_hub/climate.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
ATTR_SERIAL,
@@ -158,7 +158,7 @@ class NoboZone(ClimateEntity):
@callback
def _read_state(self) -> None:
"""Read the current state from the hub. These are only local calls."""
- state = self._nobo.get_current_zone_mode(self._id, dt.now())
+ state = self._nobo.get_current_zone_mode(self._id, dt_util.now())
self._attr_hvac_mode = HVACMode.AUTO
self._attr_preset_mode = PRESET_NONE
diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json
index 12cd22149d32..d04e07f02146 100644
--- a/homeassistant/components/norway_air/manifest.json
+++ b/homeassistant/components/norway_air/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/norway_air",
"iot_class": "cloud_polling",
"loggers": ["metno"],
- "requirements": ["pyMetno==0.9.0"]
+ "requirements": ["pyMetno==0.10.0"]
}
diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json
index 8b87816fb7d0..b84bee660c1b 100644
--- a/homeassistant/components/nuki/manifest.json
+++ b/homeassistant/components/nuki/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nuki",
"iot_class": "local_polling",
"loggers": ["pynuki"],
- "requirements": ["pynuki==1.6.1"]
+ "requirements": ["pynuki==1.6.2"]
}
diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py
index e090a21a23ee..2ad63c75e040 100644
--- a/homeassistant/components/number/__init__.py
+++ b/homeassistant/components/number/__init__.py
@@ -127,10 +127,11 @@ class NumberEntityDescription(EntityDescription):
device_class: NumberDeviceClass | None = None
max_value: None = None
min_value: None = None
+ mode: NumberMode | None = None
native_max_value: float | None = None
native_min_value: float | None = None
- native_unit_of_measurement: str | None = None
native_step: float | None = None
+ native_unit_of_measurement: str | None = None
step: None = None
unit_of_measurement: None = None # Type override, use native_unit_of_measurement
@@ -193,7 +194,7 @@ class NumberEntity(Entity):
_attr_device_class: NumberDeviceClass | None
_attr_max_value: None
_attr_min_value: None
- _attr_mode: NumberMode = NumberMode.AUTO
+ _attr_mode: NumberMode
_attr_state: None = None
_attr_step: None
_attr_unit_of_measurement: None # Subclasses of NumberEntity should not set this
@@ -201,8 +202,8 @@ class NumberEntity(Entity):
_attr_native_max_value: float
_attr_native_min_value: float
_attr_native_step: float
- _attr_native_value: float | None = None
_attr_native_unit_of_measurement: str | None
+ _attr_native_value: float | None = None
_deprecated_number_entity_reported = False
_number_option_unit_of_measurement: str | None = None
@@ -357,7 +358,14 @@ class NumberEntity(Entity):
@property
def mode(self) -> NumberMode:
"""Return the mode of the entity."""
- return self._attr_mode
+ if hasattr(self, "_attr_mode"):
+ return self._attr_mode
+ if (
+ hasattr(self, "entity_description")
+ and self.entity_description.mode is not None
+ ):
+ return self.entity_description.mode
+ return NumberMode.AUTO
@property
@final
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index 48cd04dc26e8..b0542aa588a8 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.backports.enum import StrEnum
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
@@ -309,6 +310,12 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `µg/m³`
"""
+ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
+ """Ratio of VOC.
+
+ Unit of measurement: `ppm`, `ppb`
+ """
+
VOLTAGE = "voltage"
"""Voltage.
@@ -421,6 +428,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
},
+ NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
+ },
NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
NumberDeviceClass.VOLUME: set(UnitOfVolume),
NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume),
diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json
index 46db471305c3..9af54311129f 100644
--- a/homeassistant/components/number/strings.json
+++ b/homeassistant/components/number/strings.json
@@ -154,5 +154,11 @@
"wind_speed": {
"name": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py
index 12cb9e25f849..82e42b67586b 100644
--- a/homeassistant/components/obihai/__init__.py
+++ b/homeassistant/components/obihai/__init__.py
@@ -3,6 +3,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import format_mac
from .connectivity import ObihaiConnection
from .const import LOGGER, PLATFORMS
@@ -29,10 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.async_add_executor_job(requester.update)
- new_unique_id = await hass.async_add_executor_job(
+ device_mac = await hass.async_add_executor_job(
requester.pyobihai.get_device_mac
)
- hass.config_entries.async_update_entry(entry, unique_id=new_unique_id)
+ hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac))
entry.version = 2
diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py
index 6216fe0b9730..1790add84f07 100644
--- a/homeassistant/components/obihai/config_flow.py
+++ b/homeassistant/components/obihai/config_flow.py
@@ -10,9 +10,10 @@ import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers.device_registry import format_mac
from .connectivity import validate_auth
from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN
@@ -77,7 +78,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
device_mac = await self.hass.async_add_executor_job(
pyobihai.get_device_mac
)
- await self.async_set_unique_id(device_mac)
+ await self.async_set_unique_id(format_mac(device_mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -104,7 +105,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Attempt to confirm."""
assert self._dhcp_discovery_info
- await self.async_set_unique_id(self._dhcp_discovery_info.macaddress)
+ await self.async_set_unique_id(format_mac(self._dhcp_discovery_info.macaddress))
self._abort_if_unique_id_configured()
if user_input is None:
@@ -135,28 +136,3 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN):
)
return await self.async_step_user(user_input=user_input)
-
- # DEPRECATED
- async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
- """Handle a flow initialized by importing a config."""
-
- try:
- _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST])
- except gaierror:
- return self.async_abort(reason="cannot_connect")
-
- if pyobihai := await async_validate_creds(self.hass, config):
- device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac)
- await self.async_set_unique_id(device_mac)
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=config.get(CONF_NAME, config[CONF_HOST]),
- data={
- CONF_HOST: config[CONF_HOST],
- CONF_PASSWORD: config[CONF_PASSWORD],
- CONF_USERNAME: config[CONF_USERNAME],
- },
- )
-
- return self.async_abort(reason="invalid_auth")
diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py
index 61411b0ce271..010b97800766 100644
--- a/homeassistant/components/obihai/sensor.py
+++ b/homeassistant/components/obihai/sensor.py
@@ -4,61 +4,18 @@ from __future__ import annotations
from datetime import timedelta
from pyobihai import PyObihai
-import voluptuous as vol
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA,
- SensorDeviceClass,
- SensorEntity,
-)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .connectivity import ObihaiConnection
-from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI
+from .const import OBIHAI
SCAN_INTERVAL = timedelta(seconds=5)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
- }
-)
-
-
-# DEPRECATED
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Obihai sensor platform."""
- ir.async_create_issue(
- hass,
- DOMAIN,
- "manual_migration",
- breaks_in_ha_version="2023.6.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="manual_migration",
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config,
- )
- )
-
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@@ -93,65 +50,37 @@ class ObihaiServiceSensors(SensorEntity):
def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None:
"""Initialize monitor sensor."""
self._service_name = service_name
- self._state = None
- self._name = f"{OBIHAI} {self._service_name}"
+ self._attr_name = f"{OBIHAI} {self._service_name}"
self._pyobihai = pyobihai
- self._unique_id = f"{serial}-{self._service_name}"
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- return self._state
-
- @property
- def available(self):
- """Return if sensor is available."""
- if self._state is not None:
- return True
- return False
-
- @property
- def unique_id(self):
- """Return the unique ID."""
- return self._unique_id
-
- @property
- def device_class(self):
- """Return the device class for uptime sensor."""
+ self._attr_unique_id = f"{serial}-{self._service_name}"
if self._service_name == "Last Reboot":
- return SensorDeviceClass.TIMESTAMP
- return None
+ self._attr_device_class = SensorDeviceClass.TIMESTAMP
@property
- def icon(self):
+ def icon(self) -> str:
"""Return an icon."""
if self._service_name == "Call Direction":
- if self._state == "No Active Calls":
+ if self._attr_native_value == "No Active Calls":
return "mdi:phone-off"
- if self._state == "Inbound Call":
+ if self._attr_native_value == "Inbound Call":
return "mdi:phone-incoming"
return "mdi:phone-outgoing"
if "Caller Info" in self._service_name:
return "mdi:phone-log"
if "Port" in self._service_name:
- if self._state == "Ringing":
+ if self._attr_native_value == "Ringing":
return "mdi:phone-ring"
- if self._state == "Off Hook":
+ if self._attr_native_value == "Off Hook":
return "mdi:phone-in-talk"
return "mdi:phone-hangup"
if "Service Status" in self._service_name:
if "OBiTALK Service Status" in self._service_name:
return "mdi:phone-check"
- if self._state == "0":
+ if self._attr_native_value == "0":
return "mdi:phone-hangup"
return "mdi:phone-in-talk"
if "Reboot Required" in self._service_name:
- if self._state == "false":
+ if self._attr_native_value == "false":
return "mdi:restart-off"
return "mdi:restart-alert"
return "mdi:phone"
@@ -159,20 +88,25 @@ class ObihaiServiceSensors(SensorEntity):
def update(self) -> None:
"""Update the sensor."""
if not self._pyobihai.check_account():
- self._state = None
+ self._attr_native_value = None
+ self._attr_available = False
return
services = self._pyobihai.get_state()
if self._service_name in services:
- self._state = services.get(self._service_name)
+ self._attr_native_value = services.get(self._service_name)
services = self._pyobihai.get_line_state()
if services is not None and self._service_name in services:
- self._state = services.get(self._service_name)
+ self._attr_native_value = services.get(self._service_name)
call_direction = self._pyobihai.get_call_direction()
if self._service_name in call_direction:
- self._state = call_direction.get(self._service_name)
+ self._attr_native_value = call_direction.get(self._service_name)
+
+ if self._attr_native_value is None:
+ self._attr_available = False
+ self._attr_available = True
diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json
index 1b91cd606543..823bc2e1b8de 100644
--- a/homeassistant/components/obihai/strings.json
+++ b/homeassistant/components/obihai/strings.json
@@ -24,11 +24,5 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
- },
- "issues": {
- "manual_migration": {
- "title": "Obihai YAML configuration is being removed",
- "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file."
- }
}
}
diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py
index c36f19fd28d9..d334a0051c3d 100644
--- a/homeassistant/components/onboarding/__init__.py
+++ b/homeassistant/components/onboarding/__init__.py
@@ -2,6 +2,7 @@
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -19,6 +20,8 @@ from .const import (
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 4
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
class OnboadingStorage(Store):
"""Store onboarding data."""
diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py
index 36b4a28dffd4..ea6cd542fea7 100644
--- a/homeassistant/components/onvif/__init__.py
+++ b/homeassistant/components/onvif/__init__.py
@@ -1,10 +1,11 @@
"""The ONVIF integration."""
import asyncio
+from contextlib import suppress
from http import HTTPStatus
import logging
from httpx import RequestError
-from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError
+from onvif.exceptions import ONVIFError
from onvif.util import is_auth_error, stringify_onvif_error
from zeep.exceptions import Fault, TransportError
@@ -20,7 +21,13 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN
+from .const import (
+ CONF_ENABLE_WEBHOOKS,
+ CONF_SNAPSHOT_AUTH,
+ DEFAULT_ARGUMENTS,
+ DEFAULT_ENABLE_WEBHOOKS,
+ DOMAIN,
+)
from .device import ONVIFDevice
LOGGER = logging.getLogger(__name__)
@@ -114,35 +121,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, device.platforms)
-async def _get_snapshot_auth(device):
+async def _get_snapshot_auth(device: ONVIFDevice) -> str | None:
"""Determine auth type for snapshots."""
- if not device.capabilities.snapshot or not (device.username and device.password):
- return HTTP_DIGEST_AUTHENTICATION
+ if not device.capabilities.snapshot:
+ return None
- try:
- snapshot = await device.device.get_snapshot(device.profiles[0].token)
+ for basic_auth in (False, True):
+ method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION
+ with suppress(ONVIFError):
+ if await device.device.get_snapshot(device.profiles[0].token, basic_auth):
+ return method
- if snapshot:
- return HTTP_DIGEST_AUTHENTICATION
- return HTTP_BASIC_AUTHENTICATION
- except (ONVIFAuthError, ONVIFTimeoutError):
- return HTTP_BASIC_AUTHENTICATION
- except ONVIFError:
- return HTTP_DIGEST_AUTHENTICATION
+ return None
-async def async_populate_snapshot_auth(hass, device, entry):
+async def async_populate_snapshot_auth(
+ hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry
+) -> None:
"""Check if digest auth for snapshots is possible."""
- auth = await _get_snapshot_auth(device)
- new_data = {**entry.data, CONF_SNAPSHOT_AUTH: auth}
- hass.config_entries.async_update_entry(entry, data=new_data)
+ if auth := await _get_snapshot_auth(device):
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth}
+ )
-async def async_populate_options(hass, entry):
+async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Populate default options for device."""
options = {
CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS,
CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)),
+ CONF_ENABLE_WEBHOOKS: DEFAULT_ENABLE_WEBHOOKS,
}
hass.config_entries.async_update_entry(entry, options=options)
diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py
index da948787e495..c1df94f5f838 100644
--- a/homeassistant/components/onvif/config_flow.py
+++ b/homeassistant/components/onvif/config_flow.py
@@ -34,7 +34,10 @@ from homeassistant.helpers import device_registry as dr
from .const import (
CONF_DEVICE_ID,
+ CONF_ENABLE_WEBHOOKS,
+ CONF_HARDWARE,
DEFAULT_ARGUMENTS,
+ DEFAULT_ENABLE_WEBHOOKS,
DEFAULT_PORT,
DOMAIN,
GET_CAPABILITIES_EXCEPTIONS,
@@ -48,12 +51,15 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device"
def wsdiscovery() -> list[Service]:
"""Get ONVIF Profile S devices from network."""
discovery = WSDiscovery(ttl=4)
- discovery.start()
- services = discovery.searchServices(
- scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")]
- )
- discovery.stop()
- return services
+ try:
+ discovery.start()
+ return discovery.searchServices(
+ scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")]
+ )
+ finally:
+ discovery.stop()
+ # Stop the threads started by WSDiscovery since otherwise there is a leak.
+ discovery._stopThreads() # pylint: disable=protected-access
async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]:
@@ -69,11 +75,14 @@ async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]:
CONF_NAME: service.getEPR(),
CONF_HOST: url.hostname,
CONF_PORT: url.port or 80,
+ CONF_HARDWARE: None,
}
for scope in service.getScopes():
scope_str = scope.getValue()
if scope_str.lower().startswith("onvif://www.onvif.org/name"):
device[CONF_NAME] = scope_str.split("/")[-1]
+ if scope_str.lower().startswith("onvif://www.onvif.org/hardware"):
+ device[CONF_HARDWARE] = scope_str.split("/")[-1]
if scope_str.lower().startswith("onvif://www.onvif.org/mac"):
device[CONF_DEVICE_ID] = scope_str.split("/")[-1]
devices.append(device)
@@ -190,8 +199,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_configure()
for device in self.devices:
- name = f"{device[CONF_NAME]} ({device[CONF_HOST]})"
- if name == user_input[CONF_HOST]:
+ if device[CONF_HOST] == user_input[CONF_HOST]:
self.device_id = device[CONF_DEVICE_ID]
self.onvif_config = {
CONF_NAME: device[CONF_NAME],
@@ -213,15 +221,16 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices))
if self.devices:
- names = [
- f"{device[CONF_NAME]} ({device[CONF_HOST]})" for device in self.devices
- ]
-
- names.append(CONF_MANUAL_INPUT)
+ devices = {CONF_MANUAL_INPUT: CONF_MANUAL_INPUT}
+ for device in self.devices:
+ description = f"{device[CONF_NAME]} ({device[CONF_HOST]})"
+ if hardware := device[CONF_HARDWARE]:
+ description += f" [{hardware}]"
+ devices[device[CONF_HOST]] = description
return self.async_show_form(
step_id="device",
- data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(names)}),
+ data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(devices)}),
)
return await self.async_step_configure()
@@ -387,6 +396,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow):
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
self.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
)
+ self.options[CONF_ENABLE_WEBHOOKS] = user_input.get(
+ CONF_ENABLE_WEBHOOKS,
+ self.config_entry.options.get(
+ CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS
+ ),
+ )
return self.async_create_entry(title="", data=self.options)
advanced_options = {}
@@ -415,6 +430,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow):
CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS))
),
): vol.In(RTSP_TRANSPORTS),
+ vol.Optional(
+ CONF_ENABLE_WEBHOOKS,
+ default=self.config_entry.options.get(
+ CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS
+ ),
+ ): bool,
**advanced_options,
}
),
diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py
index bfe22eacbd5e..77fa098a3160 100644
--- a/homeassistant/components/onvif/const.py
+++ b/homeassistant/components/onvif/const.py
@@ -13,7 +13,10 @@ DEFAULT_PORT = 80
DEFAULT_ARGUMENTS = "-pred 1"
CONF_DEVICE_ID = "deviceid"
+CONF_HARDWARE = "hardware"
CONF_SNAPSHOT_AUTH = "snapshot_auth"
+CONF_ENABLE_WEBHOOKS = "enable_webhooks"
+DEFAULT_ENABLE_WEBHOOKS = True
ATTR_PAN = "pan"
ATTR_TILT = "tilt"
diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py
index 1152503a718f..a524d8ea5198 100644
--- a/homeassistant/components/onvif/device.py
+++ b/homeassistant/components/onvif/device.py
@@ -23,12 +23,14 @@ from homeassistant.const import (
CONF_USERNAME,
Platform,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
import homeassistant.util.dt as dt_util
from .const import (
ABSOLUTE_MOVE,
+ CONF_ENABLE_WEBHOOKS,
CONTINUOUS_MOVE,
+ DEFAULT_ENABLE_WEBHOOKS,
GET_CAPABILITIES_EXCEPTIONS,
GOTOPRESET_MOVE,
LOGGER,
@@ -52,6 +54,7 @@ class ONVIFDevice:
"""Initialize the device."""
self.hass: HomeAssistant = hass
self.config_entry: ConfigEntry = config_entry
+ self._original_options = dict(config_entry.options)
self.available: bool = True
self.info: DeviceInfo = DeviceInfo()
@@ -63,6 +66,13 @@ class ONVIFDevice:
self._dt_diff_seconds: float = 0
+ async def _async_update_listener(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Handle options update."""
+ if self._original_options != entry.options:
+ hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
+
@property
def name(self) -> str:
"""Return the name of this device."""
@@ -151,6 +161,14 @@ class ONVIFDevice:
self.capabilities.events = await self.async_start_events()
LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities)
+ # Bind the listener to the ONVIFDevice instance since
+ # async_update_listener only creates a weak reference to the listener
+ # and we need to make sure it doesn't get garbage collected since only
+ # the ONVIFDevice instance is stored in hass.data
+ self.config_entry.async_on_unload(
+ self.config_entry.add_update_listener(self._async_update_listener)
+ )
+
async def async_stop(self, event=None):
"""Shut it all down."""
if self.events:
@@ -171,14 +189,19 @@ class ONVIFDevice:
dt_param.DateTimeType = "Manual"
# Retrieve DST setting from system
dt_param.DaylightSavings = bool(time.localtime().tm_isdst)
- dt_param.UTCDateTime = device_time.UTCDateTime
+ dt_param.UTCDateTime = {
+ "Date": {
+ "Year": system_date.year,
+ "Month": system_date.month,
+ "Day": system_date.day,
+ },
+ "Time": {
+ "Hour": system_date.hour,
+ "Minute": system_date.minute,
+ "Second": system_date.second,
+ },
+ }
# Retrieve timezone from system
- dt_param.UTCDateTime.Date.Year = system_date.year
- dt_param.UTCDateTime.Date.Month = system_date.month
- dt_param.UTCDateTime.Date.Day = system_date.day
- dt_param.UTCDateTime.Time.Hour = system_date.hour
- dt_param.UTCDateTime.Time.Minute = system_date.minute
- dt_param.UTCDateTime.Time.Second = system_date.second
system_timezone = str(system_date.astimezone().tzinfo)
timezone_names: list[str | None] = [system_timezone]
if (time_zone := device_time.TimeZone) and system_timezone != time_zone.TZ:
@@ -265,6 +288,22 @@ class ONVIFDevice:
if abs(self._dt_diff_seconds) < 5:
return
+ if device_time.DateTimeType != "Manual":
+ self._async_log_time_out_of_sync(cam_date_utc, system_date)
+ return
+
+ # Set Date and Time ourselves if Date and Time is set manually in the camera.
+ try:
+ await self.async_manually_set_date_and_time()
+ except (RequestError, TransportError, IndexError, Fault):
+ LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
+ self._async_log_time_out_of_sync(cam_date_utc, system_date)
+
+ @callback
+ def _async_log_time_out_of_sync(
+ self, cam_date_utc: dt.datetime, system_date: dt.datetime
+ ) -> None:
+ """Log a warning if the camera and system date/time are not synced."""
LOGGER.warning(
(
"The date/time on %s (UTC) is '%s', "
@@ -276,15 +315,6 @@ class ONVIFDevice:
system_date,
)
- if device_time.DateTimeType != "Manual":
- return
-
- # Set Date and Time ourselves if Date and Time is set manually in the camera.
- try:
- await self.async_manually_set_date_and_time()
- except (RequestError, TransportError, IndexError, Fault):
- LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
-
async def async_get_device_info(self) -> DeviceInfo:
"""Obtain information about this device."""
device_mgmt = await self.device.create_devicemgmt_service()
@@ -357,7 +387,12 @@ class ONVIFDevice:
"WSPullPointSupport"
)
LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support)
- return await self.events.async_start(pull_point_support is not False, True)
+ return await self.events.async_start(
+ pull_point_support is not False,
+ self.config_entry.options.get(
+ CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS
+ ),
+ )
return False
diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json
index a749e59be48c..e92e80a9a68a 100644
--- a/homeassistant/components/onvif/manifest.json
+++ b/homeassistant/components/onvif/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
- "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"]
+ "requirements": ["onvif-zeep-async==3.1.9", "WSDiscovery==2.0.0"]
}
diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json
index 3e9db0b3c7e6..8e989f1dfa09 100644
--- a/homeassistant/components/onvif/strings.json
+++ b/homeassistant/components/onvif/strings.json
@@ -61,7 +61,8 @@
"data": {
"extra_arguments": "Extra FFMPEG arguments",
"rtsp_transport": "RTSP transport mechanism",
- "use_wallclock_as_timestamps": "Use wall clock as timestamps"
+ "use_wallclock_as_timestamps": "Use wall clock as timestamps",
+ "enable_webhooks": "Enable Webhooks"
},
"title": "ONVIF Device Options"
}
diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py
index a88a37f5d205..5077a65e0b0c 100644
--- a/homeassistant/components/onvif/util.py
+++ b/homeassistant/components/onvif/util.py
@@ -17,16 +17,16 @@ def stringify_onvif_error(error: Exception) -> str:
"""Stringify ONVIF error."""
if isinstance(error, Fault):
message = error.message
- if error.detail:
+ if error.detail is not None: # checking true is deprecated
# Detail may be a bytes object, so we need to convert it to string
if isinstance(error.detail, bytes):
detail = error.detail.decode("utf-8", "replace")
else:
detail = str(error.detail)
message += ": " + detail
- if error.code:
+ if error.code is not None: # checking true is deprecated
message += f" (code:{error.code})"
- if error.subcodes:
+ if error.subcodes is not None: # checking true is deprecated
message += (
f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})"
)
diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json
index 7fdb6cfd677e..854f2ec840bb 100644
--- a/homeassistant/components/opensky/manifest.json
+++ b/homeassistant/components/opensky/manifest.json
@@ -1,7 +1,8 @@
{
"domain": "opensky",
"name": "OpenSky Network",
- "codeowners": [],
+ "codeowners": ["@joostlek"],
"documentation": "https://www.home-assistant.io/integrations/opensky",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "requirements": ["python-opensky==0.0.7"]
}
diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py
index 03e242f40b29..f3704f8d5474 100644
--- a/homeassistant/components/opensky/sensor.py
+++ b/homeassistant/components/opensky/sensor.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import timedelta
-import requests
+from python_opensky import BoundingBox, OpenSky, StateVector
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
@@ -14,14 +14,12 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
- UnitOfLength,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import location as util_location
-from homeassistant.util.unit_conversion import DistanceConverter
CONF_ALTITUDE = "altitude"
@@ -79,15 +77,18 @@ def setup_platform(
"""Set up the Open Sky platform."""
latitude = config.get(CONF_LATITUDE, hass.config.latitude)
longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ radius = config.get(CONF_RADIUS, 0)
+ bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius)
+ session = async_get_clientsession(hass)
+ opensky = OpenSky(session=session)
add_entities(
[
OpenSkySensor(
hass,
config.get(CONF_NAME, DOMAIN),
- latitude,
- longitude,
- config.get(CONF_RADIUS),
- config.get(CONF_ALTITUDE),
+ opensky,
+ bounding_box,
+ config[CONF_ALTITUDE],
)
],
True,
@@ -101,38 +102,43 @@ class OpenSkySensor(SensorEntity):
"Information provided by the OpenSky Network (https://opensky-network.org)"
)
- def __init__(self, hass, name, latitude, longitude, radius, altitude):
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ name: str,
+ opensky: OpenSky,
+ bounding_box: BoundingBox,
+ altitude: float,
+ ) -> None:
"""Initialize the sensor."""
- self._session = requests.Session()
- self._latitude = latitude
- self._longitude = longitude
- self._radius = DistanceConverter.convert(
- radius, UnitOfLength.KILOMETERS, UnitOfLength.METERS
- )
self._altitude = altitude
self._state = 0
self._hass = hass
self._name = name
- self._previously_tracked = None
+ self._previously_tracked: set[str] = set()
+ self._opensky = opensky
+ self._bounding_box = bounding_box
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
- def native_value(self):
+ def native_value(self) -> int:
"""Return the state of the sensor."""
return self._state
- def _handle_boundary(self, flights, event, metadata):
+ def _handle_boundary(
+ self, flights: set[str], event: str, metadata: dict[str, StateVector]
+ ) -> None:
"""Handle flights crossing region boundary."""
for flight in flights:
if flight in metadata:
- altitude = metadata[flight].get(ATTR_ALTITUDE)
- longitude = metadata[flight].get(ATTR_LONGITUDE)
- latitude = metadata[flight].get(ATTR_LATITUDE)
- icao24 = metadata[flight].get(ATTR_ICAO24)
+ altitude = metadata[flight].barometric_altitude
+ longitude = metadata[flight].longitude
+ latitude = metadata[flight].latitude
+ icao24 = metadata[flight].icao24
else:
# Assume Flight has landed if missing.
altitude = 0
@@ -150,33 +156,27 @@ class OpenSkySensor(SensorEntity):
}
self._hass.bus.fire(event, data)
- def update(self) -> None:
+ async def async_update(self) -> None:
"""Update device state."""
currently_tracked = set()
- flight_metadata = {}
- states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES)
- for state in states:
- flight = dict(zip(OPENSKY_API_FIELDS, state))
- callsign = flight[ATTR_CALLSIGN].strip()
+ flight_metadata: dict[str, StateVector] = {}
+ response = await self._opensky.get_states(bounding_box=self._bounding_box)
+ for flight in response.states:
+ if not flight.callsign:
+ continue
+ callsign = flight.callsign.strip()
if callsign != "":
flight_metadata[callsign] = flight
else:
continue
if (
- (longitude := flight.get(ATTR_LONGITUDE)) is None
- or (latitude := flight.get(ATTR_LATITUDE)) is None
- or flight.get(ATTR_ON_GROUND)
+ flight.longitude is None
+ or flight.latitude is None
+ or flight.on_ground
+ or flight.barometric_altitude is None
):
continue
- distance = util_location.distance(
- self._latitude,
- self._longitude,
- latitude,
- longitude,
- )
- if distance is None or distance > self._radius:
- continue
- altitude = flight.get(ATTR_ALTITUDE)
+ altitude = flight.barometric_altitude
if altitude > self._altitude and self._altitude != 0:
continue
currently_tracked.add(callsign)
@@ -189,11 +189,11 @@ class OpenSkySensor(SensorEntity):
self._previously_tracked = currently_tracked
@property
- def native_unit_of_measurement(self):
+ def native_unit_of_measurement(self) -> str:
"""Return the unit of measurement."""
return "flights"
@property
- def icon(self):
+ def icon(self) -> str:
"""Return the icon."""
return "mdi:airplane"
diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py
index 90e2c426d27b..4602615769ae 100644
--- a/homeassistant/components/openweathermap/weather_update_coordinator.py
+++ b/homeassistant/components/openweathermap/weather_update_coordinator.py
@@ -12,7 +12,7 @@ from homeassistant.components.weather import (
from homeassistant.const import UnitOfTemperature
from homeassistant.helpers import sun
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import (
@@ -159,7 +159,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def _convert_forecast(self, entry):
"""Convert the forecast data."""
forecast = {
- ATTR_API_FORECAST_TIME: dt.utc_from_timestamp(
+ ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp(
entry.reference_time("unix")
).isoformat(),
ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(
@@ -252,7 +252,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Get weather condition from weather data."""
if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT:
if timestamp:
- timestamp = dt.utc_from_timestamp(timestamp)
+ timestamp = dt_util.utc_from_timestamp(timestamp)
if sun.is_up(self.hass, timestamp):
return ATTR_CONDITION_SUNNY
diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py
index 4a63fccb88c3..76104c75164c 100644
--- a/homeassistant/components/oralb/sensor.py
+++ b/homeassistant/components/oralb/sensor.py
@@ -124,3 +124,20 @@ class OralBBluetoothSensorEntity(
def native_value(self) -> str | int | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available.
+
+ The sensor is only created when the device is seen.
+
+ Since these are sleepy devices which stop broadcasting
+ when not in use, we can't rely on the last update time
+ so once we have seen the device we always return True.
+ """
+ return True
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if the device is no longer broadcasting."""
+ return not self.processor.available
diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py
index 5942d67b50d5..8f8810b5f33b 100644
--- a/homeassistant/components/otbr/__init__.py
+++ b/homeassistant/components/otbr/__init__.py
@@ -10,6 +10,7 @@ from homeassistant.components.thread import async_add_dataset
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
@@ -17,6 +18,8 @@ from . import websocket_api
from .const import DOMAIN
from .util import OTBRData, update_issues
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Open Thread Border Router component."""
diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py
index 32842ad6cc78..c8ab8246c8be 100644
--- a/homeassistant/components/otbr/config_flow.py
+++ b/homeassistant/components/otbr/config_flow.py
@@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio
import logging
+from typing import cast
import aiohttp
import python_otbr_api
from python_otbr_api import tlv_parser
+from python_otbr_api.tlv_parser import MeshcopTLVType
import voluptuous as vol
import yarl
@@ -38,8 +40,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
thread_dataset_tlv = await async_get_preferred_dataset(self.hass)
if thread_dataset_tlv:
dataset = tlv_parser.parse_tlv(thread_dataset_tlv)
- if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL):
- thread_dataset_channel = int(channel_str, base=16)
+ if channel := dataset.get(MeshcopTLVType.CHANNEL):
+ thread_dataset_channel = cast(tlv_parser.Channel, channel).channel
if thread_dataset_tlv is not None and (
not allowed_channel or allowed_channel == thread_dataset_channel
@@ -50,7 +52,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
"not importing TLV with channel %s", thread_dataset_channel
)
await api.create_active_dataset(
- python_otbr_api.OperationalDataSet(
+ python_otbr_api.ActiveDataSet(
channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL,
network_name="home-assistant",
)
diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json
index c10a2417dc6a..f04e15a549c6 100644
--- a/homeassistant/components/otbr/manifest.json
+++ b/homeassistant/components/otbr/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["python-otbr-api==1.0.9"]
+ "requirements": ["python-otbr-api==2.1.0"]
}
diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py
new file mode 100644
index 000000000000..9a462c4610bd
--- /dev/null
+++ b/homeassistant/components/otbr/silabs_multiprotocol.py
@@ -0,0 +1,87 @@
+"""Silicon Labs Multiprotocol support."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+import aiohttp
+from python_otbr_api import tlv_parser
+from python_otbr_api.tlv_parser import MeshcopTLVType
+
+from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
+ is_multiprotocol_url,
+)
+from homeassistant.components.thread import async_add_dataset
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+from . import DOMAIN
+from .util import OTBRData
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_change_channel(hass: HomeAssistant, channel: int, delay: float) -> None:
+ """Set the channel to be used.
+
+ Does nothing if not configured.
+ """
+ if DOMAIN not in hass.data:
+ return
+
+ data: OTBRData = hass.data[DOMAIN]
+ await data.set_channel(channel, delay)
+
+ # Import the new dataset
+ dataset_tlvs = await data.get_pending_dataset_tlvs()
+ if dataset_tlvs is None:
+ # The activation timer may have expired already
+ dataset_tlvs = await data.get_active_dataset_tlvs()
+ if dataset_tlvs is None:
+ # Don't try to import a None dataset
+ return
+
+ dataset = tlv_parser.parse_tlv(dataset_tlvs.hex())
+ dataset.pop(MeshcopTLVType.DELAYTIMER, None)
+ dataset.pop(MeshcopTLVType.PENDINGTIMESTAMP, None)
+ dataset_tlvs_str = tlv_parser.encode_tlv(dataset)
+ await async_add_dataset(hass, DOMAIN, dataset_tlvs_str)
+
+
+async def async_get_channel(hass: HomeAssistant) -> int | None:
+ """Return the channel.
+
+ Returns None if not configured.
+ """
+ if DOMAIN not in hass.data:
+ return None
+
+ data: OTBRData = hass.data[DOMAIN]
+
+ try:
+ dataset = await data.get_active_dataset()
+ except (
+ HomeAssistantError,
+ aiohttp.ClientError,
+ asyncio.TimeoutError,
+ ) as err:
+ _LOGGER.warning("Failed to communicate with OTBR %s", err)
+ return None
+
+ if dataset is None:
+ return None
+
+ return dataset.channel
+
+
+async def async_using_multipan(hass: HomeAssistant) -> bool:
+ """Return if the multiprotocol device is used.
+
+ Returns False if not configured.
+ """
+ if DOMAIN not in hass.data:
+ return False
+
+ data: OTBRData = hass.data[DOMAIN]
+ return is_multiprotocol_url(data.url)
diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py
index b2ce05f280ca..5caebba5eb50 100644
--- a/homeassistant/components/otbr/util.py
+++ b/homeassistant/components/otbr/util.py
@@ -2,21 +2,22 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
-import contextlib
import dataclasses
from functools import wraps
-from typing import Any, Concatenate, ParamSpec, TypeVar
+from typing import Any, Concatenate, ParamSpec, TypeVar, cast
import python_otbr_api
-from python_otbr_api import tlv_parser
+from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser
from python_otbr_api.pskc import compute_pskc
+from python_otbr_api.tlv_parser import MeshcopTLVType
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
+ MultiprotocolAddonManager,
+ get_addon_manager,
is_multiprotocol_url,
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO
-from homeassistant.components.zha import api as zha_api
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
@@ -72,14 +73,24 @@ class OTBRData:
"""Enable or disable the router."""
return await self.api.set_enabled(enabled)
+ @_handle_otbr_error
+ async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None:
+ """Get current active operational dataset, or None."""
+ return await self.api.get_active_dataset()
+
@_handle_otbr_error
async def get_active_dataset_tlvs(self) -> bytes | None:
"""Get current active operational dataset in TLVS format, or None."""
return await self.api.get_active_dataset_tlvs()
+ @_handle_otbr_error
+ async def get_pending_dataset_tlvs(self) -> bytes | None:
+ """Get current pending operational dataset in TLVS format, or None."""
+ return await self.api.get_pending_dataset_tlvs()
+
@_handle_otbr_error
async def create_active_dataset(
- self, dataset: python_otbr_api.OperationalDataSet
+ self, dataset: python_otbr_api.ActiveDataSet
) -> None:
"""Create an active operational dataset."""
return await self.api.create_active_dataset(dataset)
@@ -89,43 +100,27 @@ class OTBRData:
"""Set current active operational dataset in TLVS format."""
await self.api.set_active_dataset_tlvs(dataset)
+ @_handle_otbr_error
+ async def set_channel(
+ self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000
+ ) -> None:
+ """Set current channel."""
+ await self.api.set_channel(channel, delay=int(delay * 1000))
+
@_handle_otbr_error
async def get_extended_address(self) -> bytes:
"""Get extended address (EUI-64)."""
return await self.api.get_extended_address()
-def _get_zha_url(hass: HomeAssistant) -> str | None:
- """Get ZHA radio path, or None if there's no ZHA config entry."""
- with contextlib.suppress(ValueError):
- return zha_api.async_get_radio_path(hass)
- return None
-
-
-async def _get_zha_channel(hass: HomeAssistant) -> int | None:
- """Get ZHA channel, or None if there's no ZHA config entry."""
- zha_network_settings: zha_api.NetworkBackup | None
- with contextlib.suppress(ValueError):
- zha_network_settings = await zha_api.async_get_network_settings(hass)
- if not zha_network_settings:
- return None
- channel: int = zha_network_settings.network_info.channel
- # ZHA uses channel 0 when no channel is set
- return channel or None
-
-
async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None:
"""Return the allowed channel, or None if there's no restriction."""
if not is_multiprotocol_url(otbr_url):
# The OTBR is not sharing the radio, no restriction
return None
- zha_url = _get_zha_url(hass)
- if not zha_url or not is_multiprotocol_url(zha_url):
- # ZHA is not configured or not sharing the radio with this OTBR, no restriction
- return None
-
- return await _get_zha_channel(hass)
+ addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass)
+ return addon_manager.async_get_channel()
async def _warn_on_channel_collision(
@@ -146,14 +141,10 @@ async def _warn_on_channel_collision(
dataset = tlv_parser.parse_tlv(dataset_tlvs.hex())
- if (channel_s := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None:
- delete_issue()
- return
- try:
- channel = int(channel_s, 16)
- except ValueError:
+ if (channel_s := dataset.get(MeshcopTLVType.CHANNEL)) is None:
delete_issue()
return
+ channel = cast(tlv_parser.Channel, channel_s).channel
if channel == allowed_channel:
delete_issue()
@@ -186,20 +177,20 @@ def _warn_on_default_network_settings(
insecure = False
if (
- network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY)
- ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS:
+ network_key := dataset.get(MeshcopTLVType.NETWORKKEY)
+ ) is not None and network_key.data in INSECURE_NETWORK_KEYS:
insecure = True
if (
not insecure
- and tlv_parser.MeshcopTLVType.EXTPANID in dataset
- and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset
- and tlv_parser.MeshcopTLVType.PSKC in dataset
+ and MeshcopTLVType.EXTPANID in dataset
+ and MeshcopTLVType.NETWORKNAME in dataset
+ and MeshcopTLVType.PSKC in dataset
):
- ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID]
- network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME]
- pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC])
+ ext_pan_id = dataset[MeshcopTLVType.EXTPANID]
+ network_name = cast(tlv_parser.NetworkName, dataset[MeshcopTLVType.NETWORKNAME])
+ pskc = dataset[MeshcopTLVType.PSKC].data
for passphrase in INSECURE_PASSPHRASES:
- if pskc == compute_pskc(ext_pan_id, network_name, passphrase):
+ if pskc == compute_pskc(ext_pan_id.data, network_name.name, passphrase):
insecure = True
break
diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py
index 2189df363ba0..0dcce288348b 100644
--- a/homeassistant/components/otbr/websocket_api.py
+++ b/homeassistant/components/otbr/websocket_api.py
@@ -1,7 +1,10 @@
"""Websocket API for OTBR."""
+from typing import cast
+
import python_otbr_api
from python_otbr_api import tlv_parser
+from python_otbr_api.tlv_parser import MeshcopTLVType
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -80,7 +83,7 @@ async def websocket_create_network(
try:
await data.create_active_dataset(
- python_otbr_api.OperationalDataSet(
+ python_otbr_api.ActiveDataSet(
channel=channel, network_name="home-assistant"
)
)
@@ -133,8 +136,8 @@ async def websocket_set_network(
connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset")
return
dataset = tlv_parser.parse_tlv(dataset_tlv)
- if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL):
- thread_dataset_channel = int(channel_str, base=16)
+ if channel := dataset.get(MeshcopTLVType.CHANNEL):
+ thread_dataset_channel = cast(tlv_parser.Channel, channel).channel
data: OTBRData = hass.data[DOMAIN]
allowed_channel = await get_allowed_channel(hass, data.url)
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index 0db01a2d84c2..102d09a76b14 100644
--- a/homeassistant/components/overkiz/const.py
+++ b/homeassistant/components/overkiz/const.py
@@ -87,6 +87,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
+ UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported)
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index dfd4a6c28e14..2d81b7bab071 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
- "requirements": ["pyoverkiz==1.7.8"],
+ "requirements": ["pyoverkiz==1.7.9"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py
index 4c70bab70f5b..9aca0850b053 100644
--- a/homeassistant/components/overkiz/sensor.py
+++ b/homeassistant/components/overkiz/sensor.py
@@ -116,8 +116,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
key=OverkizState.IO_OUTLET_ENGINE,
name="Outlet engine",
icon="mdi:fan-chevron-down",
- native_unit_of_measurement=UnitOfVolume.LITERS,
- device_class=SensorDeviceClass.VOLUME,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
OverkizSensorDescription(
diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json
index 78c132eafbcc..fa0202c08718 100644
--- a/homeassistant/components/panasonic_bluray/manifest.json
+++ b/homeassistant/components/panasonic_bluray/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/panasonic_bluray",
"iot_class": "local_polling",
"loggers": ["panacotta"],
- "requirements": ["panacotta==0.1"]
+ "requirements": ["panacotta==0.2"]
}
diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py
index 36b496ddde21..fe8849c7788e 100644
--- a/homeassistant/components/persistent_notification/__init__.py
+++ b/homeassistant/components/persistent_notification/__init__.py
@@ -2,43 +2,65 @@
from __future__ import annotations
from collections.abc import Mapping
+from datetime import datetime
import logging
-from typing import Any
+from typing import Any, Final, TypedDict
import voluptuous as vol
+from homeassistant.backports.enum import StrEnum
from homeassistant.components import websocket_api
-from homeassistant.const import ATTR_FRIENDLY_NAME
-from homeassistant.core import Context, HomeAssistant, ServiceCall, callback
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.helpers import config_validation as cv, singleton
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
-from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
-
-ATTR_CREATED_AT = "created_at"
-ATTR_MESSAGE = "message"
-ATTR_NOTIFICATION_ID = "notification_id"
-ATTR_TITLE = "title"
-ATTR_STATUS = "status"
+from homeassistant.util.uuid import random_uuid_hex
DOMAIN = "persistent_notification"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
+ATTR_CREATED_AT: Final = "created_at"
+ATTR_MESSAGE: Final = "message"
+ATTR_NOTIFICATION_ID: Final = "notification_id"
+ATTR_TITLE: Final = "title"
+ATTR_STATUS: Final = "status"
+
+# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated"
+
+class Notification(TypedDict):
+ """Persistent notification."""
+
+ created_at: datetime
+ message: str
+ notification_id: str
+ title: str | None
+
+
+class UpdateType(StrEnum):
+ """Persistent notification update type."""
+
+ CURRENT = "current"
+ ADDED = "added"
+ REMOVED = "removed"
+ UPDATED = "updated"
+
+
+SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated"
+
SCHEMA_SERVICE_NOTIFICATION = vol.Schema(
{vol.Required(ATTR_NOTIFICATION_ID): cv.string}
)
-DEFAULT_OBJECT_ID = "notification"
_LOGGER = logging.getLogger(__name__)
-STATE = "notifying"
-STATUS_UNREAD = "unread"
-STATUS_READ = "read"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@bind_hass
@@ -65,64 +87,50 @@ def async_create(
message: str,
title: str | None = None,
notification_id: str | None = None,
- *,
- context: Context | None = None,
) -> None:
"""Generate a notification."""
- if (notifications := hass.data.get(DOMAIN)) is None:
- notifications = hass.data[DOMAIN] = {}
-
- if notification_id is not None:
- entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
- else:
- entity_id = async_generate_entity_id(
- ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass
- )
- notification_id = entity_id.split(".")[1]
-
- attr: dict[str, str] = {ATTR_MESSAGE: message}
- if title is not None:
- attr[ATTR_TITLE] = title
- attr[ATTR_FRIENDLY_NAME] = title
-
- hass.states.async_set(entity_id, STATE, attr, context=context)
-
- # Store notification and fire event
- # This will eventually replace state machine storage
- notifications[entity_id] = {
+ notifications = _async_get_or_create_notifications(hass)
+ if notification_id is None:
+ notification_id = random_uuid_hex()
+ notifications[notification_id] = {
ATTR_MESSAGE: message,
ATTR_NOTIFICATION_ID: notification_id,
- ATTR_STATUS: STATUS_UNREAD,
ATTR_TITLE: title,
ATTR_CREATED_AT: dt_util.utcnow(),
}
- hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=context)
+ async_dispatcher_send(
+ hass,
+ SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
+ UpdateType.ADDED,
+ {notification_id: notifications[notification_id]},
+ )
+
+
+@callback
+@singleton.singleton(DOMAIN)
+def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notification]:
+ """Get or create notifications data."""
+ return {}
@callback
@bind_hass
-def async_dismiss(
- hass: HomeAssistant, notification_id: str, *, context: Context | None = None
-) -> None:
+def async_dismiss(hass: HomeAssistant, notification_id: str) -> None:
"""Remove a notification."""
- if (notifications := hass.data.get(DOMAIN)) is None:
- notifications = hass.data[DOMAIN] = {}
-
- entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
-
- if entity_id not in notifications:
+ notifications = _async_get_or_create_notifications(hass)
+ if not (notification := notifications.pop(notification_id, None)):
return
-
- hass.states.async_remove(entity_id, context)
-
- del notifications[entity_id]
- hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
+ async_dispatcher_send(
+ hass,
+ SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
+ UpdateType.REMOVED,
+ {notification_id: notification},
+ )
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the persistent notification component."""
- notifications = hass.data.setdefault(DOMAIN, {})
@callback
def create_service(call: ServiceCall) -> None:
@@ -132,34 +140,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
call.data[ATTR_MESSAGE],
call.data.get(ATTR_TITLE),
call.data.get(ATTR_NOTIFICATION_ID),
- context=call.context,
)
@callback
def dismiss_service(call: ServiceCall) -> None:
"""Handle the dismiss notification service call."""
- async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID], context=call.context)
-
- @callback
- def mark_read_service(call: ServiceCall) -> None:
- """Handle the mark_read notification service call."""
- notification_id = call.data.get(ATTR_NOTIFICATION_ID)
- entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
-
- if entity_id not in notifications:
- _LOGGER.error(
- (
- "Marking persistent_notification read failed: "
- "Notification ID %s not found"
- ),
- notification_id,
- )
- return
-
- notifications[entity_id][ATTR_STATUS] = STATUS_READ
- hass.bus.async_fire(
- EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=call.context
- )
+ async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID])
hass.services.async_register(
DOMAIN,
@@ -178,11 +164,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION
)
- hass.services.async_register(
- DOMAIN, "mark_read", mark_read_service, SCHEMA_SERVICE_NOTIFICATION
- )
-
websocket_api.async_register_command(hass, websocket_get_notifications)
+ websocket_api.async_register_command(hass, websocket_subscribe_notifications)
return True
@@ -197,19 +180,36 @@ def websocket_get_notifications(
"""Return a list of persistent_notifications."""
connection.send_message(
websocket_api.result_message(
- msg["id"],
- [
- {
- key: data[key]
- for key in (
- ATTR_NOTIFICATION_ID,
- ATTR_MESSAGE,
- ATTR_STATUS,
- ATTR_TITLE,
- ATTR_CREATED_AT,
- )
- }
- for data in hass.data[DOMAIN].values()
- ],
+ msg["id"], list(_async_get_or_create_notifications(hass).values())
)
)
+
+
+@callback
+@websocket_api.websocket_command(
+ {vol.Required("type"): "persistent_notification/subscribe"}
+)
+def websocket_subscribe_notifications(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: Mapping[str, Any],
+) -> None:
+ """Return a list of persistent_notifications."""
+ notifications = _async_get_or_create_notifications(hass)
+ msg_id = msg["id"]
+
+ @callback
+ def _async_send_notification_update(
+ update_type: UpdateType, notifications: dict[str, Notification]
+ ) -> None:
+ connection.send_message(
+ websocket_api.event_message(
+ msg["id"], {"type": update_type, "notifications": notifications}
+ )
+ )
+
+ connection.subscriptions[msg_id] = async_dispatcher_connect(
+ hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update
+ )
+ connection.send_result(msg_id)
+ _async_send_notification_update(UpdateType.CURRENT, notifications)
diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml
index 5695a3c3b820..60dbf5c864ac 100644
--- a/homeassistant/components/persistent_notification/services.yaml
+++ b/homeassistant/components/persistent_notification/services.yaml
@@ -33,15 +33,3 @@ dismiss:
example: 1234
selector:
text:
-
-mark_read:
- name: Mark read
- description: Mark a notification read.
- fields:
- notification_id:
- name: Notification ID
- description: Target ID of the notification, which should be mark read.
- required: true
- example: 1234
- selector:
- text:
diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py
index fe6925b48478..ea325380e111 100644
--- a/homeassistant/components/person/__init__.py
+++ b/homeassistant/components/person/__init__.py
@@ -47,6 +47,9 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.integration_platform import (
+ async_process_integration_platform_for_component,
+)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
@@ -330,6 +333,9 @@ The following persons point at invalid users:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the person component."""
+ # Process integration platforms right away since
+ # we will create entities before firing EVENT_COMPONENT_LOADED
+ await async_process_integration_platform_for_component(hass, DOMAIN)
entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass)
id_manager = collection.IDManager()
yaml_collection = collection.YamlCollection(
diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py
index f9c93edb4fc0..23e94b5206d1 100644
--- a/homeassistant/components/picotts/tts.py
+++ b/homeassistant/components/picotts/tts.py
@@ -46,7 +46,7 @@ class PicoProvider(Provider):
"""Return list of supported languages."""
return SUPPORT_LANGUAGES
- def get_tts_audio(self, message, language, options=None):
+ def get_tts_audio(self, message, language, options):
"""Load TTS using pico2wave."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf:
fname = tmpf.name
diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py
index 2236b8dc3374..b78702de54d2 100644
--- a/homeassistant/components/ping/__init__.py
+++ b/homeassistant/components/ping/__init__.py
@@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the template integration."""
+ """Set up the ping integration."""
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
hass.data[DOMAIN] = {
PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege),
diff --git a/homeassistant/components/piper/__init__.py b/homeassistant/components/piper/__init__.py
new file mode 100644
index 000000000000..6a67fa3975f2
--- /dev/null
+++ b/homeassistant/components/piper/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Piper."""
diff --git a/homeassistant/components/piper/manifest.json b/homeassistant/components/piper/manifest.json
new file mode 100644
index 000000000000..de8f1dede516
--- /dev/null
+++ b/homeassistant/components/piper/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "piper",
+ "name": "Piper",
+ "integration_type": "virtual",
+ "supported_by": "wyoming"
+}
diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py
index 3fce1509c0f3..956dd7f36daa 100644
--- a/homeassistant/components/plugwise/binary_sensor.py
+++ b/homeassistant/components/plugwise/binary_sensor.py
@@ -1,10 +1,12 @@
"""Plugwise Binary Sensor component for Home Assistant."""
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
+from plugwise import SmileBinarySensors
+
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
@@ -22,7 +24,16 @@ SEVERITIES = ["other", "info", "warning", "error"]
@dataclass
-class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription):
+class PlugwiseBinarySensorMixin:
+ """Mixin for required Plugwise binary sensor base description keys."""
+
+ value_fn: Callable[[SmileBinarySensors], bool]
+
+
+@dataclass
+class PlugwiseBinarySensorEntityDescription(
+ BinarySensorEntityDescription, PlugwiseBinarySensorMixin
+):
"""Describes a Plugwise binary sensor entity."""
icon_off: str | None = None
@@ -35,12 +46,14 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:hvac",
icon_off="mdi:hvac-off",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["compressor_state"],
),
PlugwiseBinarySensorEntityDescription(
key="cooling_enabled",
translation_key="cooling_enabled",
icon="mdi:snowflake-thermometer",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["cooling_enabled"],
),
PlugwiseBinarySensorEntityDescription(
key="dhw_state",
@@ -48,6 +61,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:water-pump",
icon_off="mdi:water-pump-off",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["dhw_state"],
),
PlugwiseBinarySensorEntityDescription(
key="flame_state",
@@ -56,6 +70,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:fire",
icon_off="mdi:fire-off",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["flame_state"],
),
PlugwiseBinarySensorEntityDescription(
key="heating_state",
@@ -63,6 +78,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:radiator",
icon_off="mdi:radiator-off",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["heating_state"],
),
PlugwiseBinarySensorEntityDescription(
key="cooling_state",
@@ -70,6 +86,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:snowflake",
icon_off="mdi:snowflake-off",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["cooling_state"],
),
PlugwiseBinarySensorEntityDescription(
key="slave_boiler_state",
@@ -77,6 +94,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:fire",
icon_off="mdi:circle-off-outline",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["slave_boiler_state"],
),
PlugwiseBinarySensorEntityDescription(
key="plugwise_notification",
@@ -84,6 +102,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
icon="mdi:mailbox-up-outline",
icon_off="mdi:mailbox-outline",
entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data["plugwise_notification"],
),
)
@@ -100,11 +119,10 @@ async def async_setup_entry(
entities: list[PlugwiseBinarySensorEntity] = []
for device_id, device in coordinator.data.devices.items():
+ if not (binary_sensors := device.get("binary_sensors")):
+ continue
for description in BINARY_SENSORS:
- if description.key not in device and (
- "binary_sensors" not in device
- or description.key not in device["binary_sensors"]
- ):
+ if description.key not in binary_sensors:
continue
entities.append(
@@ -134,11 +152,9 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity):
self._attr_unique_id = f"{device_id}-{description.key}"
@property
- def is_on(self) -> bool | None:
+ def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
- if self.entity_description.key in self.device:
- return self.device[self.entity_description.key]
- return self.device["binary_sensors"].get(self.entity_description.key)
+ return self.entity_description.value_fn(self.device["binary_sensors"])
@property
def icon(self) -> str | None:
diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py
index c45d47004b8f..b78fd689cb93 100644
--- a/homeassistant/components/plugwise/select.py
+++ b/homeassistant/components/plugwise/select.py
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
-from plugwise import Smile
+from plugwise import DeviceData, Smile
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
@@ -23,8 +23,8 @@ class PlugwiseSelectDescriptionMixin:
"""Mixin values for Plugwise Select entities."""
command: Callable[[Smile, str, str], Awaitable[Any]]
- current_option_key: str
- options_key: str
+ value_fn: Callable[[DeviceData], str]
+ options_fn: Callable[[DeviceData], list[str]]
@dataclass
@@ -40,8 +40,8 @@ SELECT_TYPES = (
translation_key="select_schedule",
icon="mdi:calendar-clock",
command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON),
- current_option_key="selected_schedule",
- options_key="available_schedules",
+ value_fn=lambda data: data["selected_schedule"],
+ options_fn=lambda data: data.get("available_schedules"),
),
PlugwiseSelectEntityDescription(
key="select_regulation_mode",
@@ -49,8 +49,8 @@ SELECT_TYPES = (
icon="mdi:hvac",
entity_category=EntityCategory.CONFIG,
command=lambda api, loc, opt: api.set_regulation_mode(opt),
- current_option_key="regulation_mode",
- options_key="regulation_modes",
+ value_fn=lambda data: data["regulation_mode"],
+ options_fn=lambda data: data.get("regulation_modes"),
),
PlugwiseSelectEntityDescription(
key="select_dhw_mode",
@@ -58,8 +58,8 @@ SELECT_TYPES = (
icon="mdi:shower",
entity_category=EntityCategory.CONFIG,
command=lambda api, loc, opt: api.set_dhw_mode(opt),
- current_option_key="dhw_mode",
- options_key="dhw_modes",
+ value_fn=lambda data: data["dhw_mode"],
+ options_fn=lambda data: data.get("dhw_modes"),
),
)
@@ -77,10 +77,7 @@ async def async_setup_entry(
entities: list[PlugwiseSelectEntity] = []
for device_id, device in coordinator.data.devices.items():
for description in SELECT_TYPES:
- if (
- description.options_key in device
- and len(device[description.options_key]) > 1
- ):
+ if (options := description.options_fn(device)) and len(options) > 1:
entities.append(
PlugwiseSelectEntity(coordinator, device_id, description)
)
@@ -107,12 +104,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
- return self.device[self.entity_description.current_option_key]
+ return self.entity_description.value_fn(self.device)
@property
def options(self) -> list[str]:
"""Return the selectable entity options."""
- return self.device[self.entity_description.options_key]
+ return self.entity_description.options_fn(self.device)
async def async_select_option(self, option: str) -> None:
"""Change to the selected entity option."""
diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py
index d6f8a420cc84..4204ab5a4d9d 100644
--- a/homeassistant/components/plugwise/switch.py
+++ b/homeassistant/components/plugwise/switch.py
@@ -1,8 +1,12 @@
"""Plugwise Switch component for HomeAssistant."""
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
from typing import Any
+from plugwise import SmileSwitches
+
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
@@ -18,29 +22,46 @@ from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
-SWITCHES: tuple[SwitchEntityDescription, ...] = (
- SwitchEntityDescription(
+
+@dataclass
+class PlugwiseSwitchBaseMixin:
+ """Mixin for required Plugwise switch description keys."""
+
+ value_fn: Callable[[SmileSwitches], bool]
+
+
+@dataclass
+class PlugwiseSwitchEntityDescription(SwitchEntityDescription, PlugwiseSwitchBaseMixin):
+ """Describes Plugwise switch entity."""
+
+
+SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = (
+ PlugwiseSwitchEntityDescription(
key="dhw_cm_switch",
translation_key="dhw_cm_switch",
icon="mdi:water-plus",
entity_category=EntityCategory.CONFIG,
+ value_fn=lambda data: data["dhw_cm_switch"],
),
- SwitchEntityDescription(
+ PlugwiseSwitchEntityDescription(
key="lock",
translation_key="lock",
icon="mdi:lock",
entity_category=EntityCategory.CONFIG,
+ value_fn=lambda data: data["lock"],
),
- SwitchEntityDescription(
+ PlugwiseSwitchEntityDescription(
key="relay",
translation_key="relay",
device_class=SwitchDeviceClass.SWITCH,
+ value_fn=lambda data: data["relay"],
),
- SwitchEntityDescription(
+ PlugwiseSwitchEntityDescription(
key="cooling_ena_switch",
name="Cooling",
icon="mdi:snowflake-thermometer",
entity_category=EntityCategory.CONFIG,
+ value_fn=lambda data: data["cooling_ena_switch"],
),
)
@@ -54,8 +75,10 @@ async def async_setup_entry(
coordinator = hass.data[DOMAIN][config_entry.entry_id]
entities: list[PlugwiseSwitchEntity] = []
for device_id, device in coordinator.data.devices.items():
+ if not (switches := device.get("switches")):
+ continue
for description in SWITCHES:
- if "switches" not in device or description.key not in device["switches"]:
+ if description.key not in switches:
continue
entities.append(PlugwiseSwitchEntity(coordinator, device_id, description))
async_add_entities(entities)
@@ -64,11 +87,13 @@ async def async_setup_entry(
class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
"""Representation of a Plugwise plug."""
+ entity_description: PlugwiseSwitchEntityDescription
+
def __init__(
self,
coordinator: PlugwiseDataUpdateCoordinator,
device_id: str,
- description: SwitchEntityDescription,
+ description: PlugwiseSwitchEntityDescription,
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
@@ -76,9 +101,9 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
self._attr_unique_id = f"{device_id}-{description.key}"
@property
- def is_on(self) -> bool | None:
+ def is_on(self) -> bool:
"""Return True if entity is on."""
- return self.device["switches"].get(self.entity_description.key)
+ return self.entity_description.value_fn(self.device["switches"])
@plugwise_command
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py
index f558b7301c5f..ba5f25a1c024 100644
--- a/homeassistant/components/profiler/__init__.py
+++ b/homeassistant/components/profiler/__init__.py
@@ -403,11 +403,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
# Imports deferred to avoid loading modules
# in memory since usually only one part of this
# integration is used at a time
- if sys.version_info >= (3, 11):
- raise HomeAssistantError(
- "Memory profiling is not supported on Python 3.11. Please use Python 3.10."
- )
-
from guppy import hpy # pylint: disable=import-outside-toplevel
start_time = int(time.time() * 1000000)
diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json
index 81eb77537fb7..1b33c7788438 100644
--- a/homeassistant/components/profiler/manifest.json
+++ b/homeassistant/components/profiler/manifest.json
@@ -5,9 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/profiler",
"quality_scale": "internal",
- "requirements": [
- "pyprof2calltree==1.4.5",
- "guppy3==3.1.2;python_version<'3.11'",
- "objgraph==3.5.0"
- ]
+ "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"]
}
diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py
index edd501465593..0567c551d981 100644
--- a/homeassistant/components/proximity/__init__.py
+++ b/homeassistant/components/proximity/__init__.py
@@ -13,10 +13,10 @@ from homeassistant.const import (
CONF_ZONE,
UnitOfLength,
)
-from homeassistant.core import HomeAssistant, State
+from homeassistant.core import HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import track_state_change
+from homeassistant.helpers.event import async_track_state_change
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.location import distance
from homeassistant.util.unit_conversion import DistanceConverter
@@ -62,7 +62,8 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup_proximity_component(
+@callback
+def async_setup_proximity_component(
hass: HomeAssistant, name: str, config: ConfigType
) -> bool:
"""Set up the individual proximity component."""
@@ -89,17 +90,19 @@ def setup_proximity_component(
)
proximity.entity_id = f"{DOMAIN}.{proximity_zone}"
- proximity.schedule_update_ha_state()
+ proximity.async_write_ha_state()
- track_state_change(hass, proximity_devices, proximity.check_proximity_state_change)
+ async_track_state_change(
+ hass, proximity_devices, proximity.async_check_proximity_state_change
+ )
return True
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Get the zones and offsets from configuration.yaml."""
for zone, proximity_config in config[DOMAIN].items():
- setup_proximity_component(hass, zone, proximity_config)
+ async_setup_proximity_component(hass, zone, proximity_config)
return True
@@ -152,7 +155,8 @@ class Proximity(Entity):
"""Return the state attributes."""
return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest}
- def check_proximity_state_change(
+ @callback
+ def async_check_proximity_state_change(
self, entity: str, old_state: State | None, new_state: State | None
) -> None:
"""Perform the proximity checking."""
@@ -192,7 +196,7 @@ class Proximity(Entity):
self.dist_to = "not set"
self.dir_of_travel = "not set"
self.nearest = "not set"
- self.schedule_update_ha_state()
+ self.async_write_ha_state()
return
# At least one device is in the monitored zone so update the entity.
@@ -200,7 +204,7 @@ class Proximity(Entity):
self.dist_to = 0
self.dir_of_travel = "arrived"
self.nearest = devices_in_zone
- self.schedule_update_ha_state()
+ self.async_write_ha_state()
return
# We can't check proximity because latitude and longitude don't exist.
@@ -254,7 +258,7 @@ class Proximity(Entity):
device_state = self.hass.states.get(closest_device)
assert device_state
self.nearest = device_state.name
- self.schedule_update_ha_state()
+ self.async_write_ha_state()
return
# Stop if we cannot calculate the direction of travel (i.e. we don't
@@ -263,7 +267,7 @@ class Proximity(Entity):
self.dist_to = round(distances_to_zone[entity])
self.dir_of_travel = "unknown"
self.nearest = entity_name
- self.schedule_update_ha_state()
+ self.async_write_ha_state()
return
# Reset the variables
@@ -299,7 +303,7 @@ class Proximity(Entity):
)
self.dir_of_travel = direction_of_travel
self.nearest = entity_name
- self.schedule_update_ha_state()
+ self.async_write_ha_state()
_LOGGER.debug(
"proximity.%s update entity: distance=%s: direction=%s: device=%s",
self.friendly_name,
diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py
index 2d79a0bf65ab..52eaed227e5a 100644
--- a/homeassistant/components/pushover/notify.py
+++ b/homeassistant/components/pushover/notify.py
@@ -5,21 +5,16 @@ import logging
from typing import Any
from pushover_complete import BadAPIRequestError, PushoverAPI
-import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TARGET,
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
- PLATFORM_SCHEMA,
BaseNotificationService,
)
-from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -40,11 +35,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string}
-)
-
-
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
@@ -52,17 +42,7 @@ async def async_get_service(
) -> PushoverNotificationService | None:
"""Get the Pushover notification service."""
if discovery_info is None:
- async_create_issue(
- hass,
- DOMAIN,
- "removed_yaml",
- breaks_in_ha_version="2022.11.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="removed_yaml",
- )
return None
-
pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]]
return PushoverNotificationService(
hass, pushover_api, discovery_info[CONF_USER_KEY]
diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json
index 3c2ab66bf399..cd72a9c20a28 100644
--- a/homeassistant/components/pushover/strings.json
+++ b/homeassistant/components/pushover/strings.json
@@ -24,11 +24,5 @@
}
}
}
- },
- "issues": {
- "removed_yaml": {
- "title": "The Pushover YAML configuration has been removed",
- "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py
index 1adddc485599..3606b664a3fd 100644
--- a/homeassistant/components/qld_bushfire/geo_location.py
+++ b/homeassistant/components/qld_bushfire/geo_location.py
@@ -126,7 +126,10 @@ class QldBushfireFeedEntityManager:
def _init_regular_updates(self) -> None:
"""Schedule regular updates at the specified interval."""
track_time_interval(
- self._hass, lambda now: self._feed_manager.update(), self._scan_interval
+ self._hass,
+ lambda now: self._feed_manager.update(),
+ self._scan_interval,
+ cancel_on_shutdown=True,
)
def get_entry(self, external_id: str) -> QldBushfireAlertFeedEntry | None:
diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py
index 70bd4afa880a..38a963818d49 100644
--- a/homeassistant/components/qnap_qsw/update.py
+++ b/homeassistant/components/qnap_qsw/update.py
@@ -1,7 +1,7 @@
"""Support for the QNAP QSW update."""
from __future__ import annotations
-from typing import Final
+from typing import Any, Final
from aioqsw.const import (
QSD_DESCRIPTION,
@@ -16,6 +16,7 @@ from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
+ UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
@@ -51,6 +52,7 @@ async def async_setup_entry(
class QswUpdate(QswFirmwareEntity, UpdateEntity):
"""Define a QNAP QSW update."""
+ _attr_supported_features = UpdateEntityFeature.INSTALL
entity_description: UpdateEntityDescription
def __init__(
@@ -87,3 +89,13 @@ class QswUpdate(QswFirmwareEntity, UpdateEntity):
self._attr_release_summary = self.get_device_value(
QSD_FIRMWARE_CHECK, QSD_DESCRIPTION
)
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install an update."""
+ await self.coordinator.async_refresh()
+ await self.coordinator.qsw.live_update()
+
+ self._attr_installed_version = self.latest_version
+ self.async_write_ha_state()
diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json
index aaa8671cd58d..185a034d7f21 100644
--- a/homeassistant/components/radiotherm/manifest.json
+++ b/homeassistant/components/radiotherm/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "radiotherm",
"name": "Radio Thermostat",
- "codeowners": ["@bdraco", "@vinnyfuria"],
+ "codeowners": ["@vinnyfuria"],
"config_flow": true,
"dhcp": [
{
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index 7b41a3f2f5ec..14a81f2c6651 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -1,96 +1,21 @@
"""Support for Rain Bird Irrigation system LNK WiFi Module."""
from __future__ import annotations
-import logging
-
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
-from homeassistant.const import (
- CONF_FRIENDLY_NAME,
- CONF_HOST,
- CONF_PASSWORD,
- CONF_TRIGGER_TIME,
- Platform,
-)
-from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import entity_registry as er
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
-from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES
+from .const import CONF_SERIAL_NUMBER
from .coordinator import RainbirdUpdateCoordinator
PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER]
-_LOGGER = logging.getLogger(__name__)
DOMAIN = "rainbird"
-TRIGGER_TIME_SCHEMA = vol.All(
- cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60)
-)
-
-ZONE_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_FRIENDLY_NAME): cv.string,
- vol.Optional(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA,
- }
-)
-CONTROLLER_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA,
- vol.Optional(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}),
- }
-)
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]))},
- extra=vol.ALLOW_EXTRA,
-)
-
-SERVICE_SET_RAIN_DELAY = "set_rain_delay"
-SERVICE_SCHEMA_RAIN_DELAY = vol.All(
- vol.Schema(
- {
- vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
- vol.Required(ATTR_DURATION): cv.positive_float,
- }
- ),
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Rain Bird component."""
- if DOMAIN not in config:
- return True
-
- for controller_config in config[DOMAIN]:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=controller_config,
- )
- )
-
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2023.4.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- )
-
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the config entry for Rain Bird."""
@@ -112,48 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- async def set_rain_delay(call: ServiceCall) -> None:
- """Service call to delay automatic irrigigation."""
-
- entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
- duration = call.data[ATTR_DURATION]
- if entry_id not in hass.data[DOMAIN]:
- raise HomeAssistantError(f"Config entry id does not exist: {entry_id}")
- coordinator = hass.data[DOMAIN][entry_id]
-
- entity_registry = er.async_get(hass)
- entity_ids = (
- entry.entity_id
- for entry in er.async_entries_for_config_entry(entity_registry, entry_id)
- if entry.unique_id == f"{coordinator.serial_number}-rain-delay"
- )
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_raindelay",
- breaks_in_ha_version="2023.4.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_raindelay",
- translation_placeholders={
- "alternate_target": next(entity_ids, "unknown"),
- },
- )
-
- await coordinator.controller.set_rain_delay(duration)
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_RAIN_DELAY,
- set_rain_delay,
- schema=SERVICE_SCHEMA_RAIN_DELAY,
- )
-
return True
@@ -163,12 +50,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
- hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY)
-
return unload_ok
diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py
index 3ade67c79f72..0409d0ff564c 100644
--- a/homeassistant/components/rainbird/config_flow.py
+++ b/homeassistant/components/rainbird/config_flow.py
@@ -16,7 +16,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD
+from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv, selector
@@ -24,9 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
ATTR_DURATION,
- CONF_IMPORTED_NAMES,
CONF_SERIAL_NUMBER,
- CONF_ZONES,
DEFAULT_TRIGGER_TIME_MINUTES,
DOMAIN,
TIMEOUT_SECONDS,
@@ -121,36 +119,6 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"cannot_connect",
) from err
- async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
- """Import a config entry from configuration.yaml."""
- self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]})
- try:
- serial_number = await self._test_connection(
- config[CONF_HOST], config[CONF_PASSWORD]
- )
- except ConfigFlowError as err:
- _LOGGER.error("Error during config import: %s", err)
- return self.async_abort(reason=err.error_code)
-
- data = {
- CONF_HOST: config[CONF_HOST],
- CONF_PASSWORD: config[CONF_PASSWORD],
- CONF_SERIAL_NUMBER: serial_number,
- }
- names: dict[str, str] = {}
- for zone, zone_config in config.get(CONF_ZONES, {}).items():
- if name := zone_config.get(CONF_FRIENDLY_NAME):
- names[str(zone)] = name
- if names:
- data[CONF_IMPORTED_NAMES] = names
- return await self.async_finish(
- serial_number,
- data=data,
- options={
- ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES),
- },
- )
-
async def async_finish(
self,
serial_number: str,
diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py
index 162e3a16b6c6..8055074f395c 100644
--- a/homeassistant/components/rainbird/const.py
+++ b/homeassistant/components/rainbird/const.py
@@ -4,7 +4,6 @@ DOMAIN = "rainbird"
MANUFACTURER = "Rain Bird"
DEFAULT_TRIGGER_TIME_MINUTES = 6
-CONF_ZONES = "zones"
CONF_SERIAL_NUMBER = "serial_number"
CONF_IMPORTED_NAMES = "imported_names"
diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json
index f950146f1604..3b5ae332dbd4 100644
--- a/homeassistant/components/rainbird/strings.json
+++ b/homeassistant/components/rainbird/strings.json
@@ -27,22 +27,5 @@
}
}
}
- },
- "issues": {
- "deprecated_yaml": {
- "title": "The Rain Bird YAML configuration is being removed",
- "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- },
- "deprecated_raindelay": {
- "title": "The Rain Bird Rain Delay Service is being removed",
- "fix_flow": {
- "step": {
- "confirm": {
- "title": "The Rain Bird Rain Delay Service is being removed",
- "description": "The Rain Bird service `rainbird.set_rain_delay` is being removed and replaced by a Number entity for managing the rain delay. Any existing automations or scripts will need to be updated to use `number.set_value` with a target of `{alternate_target}` instead."
- }
- }
- }
- }
}
}
diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json
index c144251960b4..d3eab0641a62 100644
--- a/homeassistant/components/rapt_ble/manifest.json
+++ b/homeassistant/components/rapt_ble/manifest.json
@@ -16,5 +16,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/rapt_ble",
"iot_class": "local_push",
- "requirements": ["rapt-ble==0.1.0"]
+ "requirements": ["rapt-ble==0.1.1"]
}
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index 7b43abd8dde9..72d825d9e785 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -135,7 +135,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
exclude_attributes_by_domain: dict[str, set[str]] = {}
hass.data[EXCLUDE_ATTRIBUTES] = exclude_attributes_by_domain
conf = config[DOMAIN]
- entity_filter = convert_include_exclude_filter(conf)
+ entity_filter = convert_include_exclude_filter(conf).get_filter()
auto_purge = conf[CONF_AUTO_PURGE]
auto_repack = conf[CONF_AUTO_REPACK]
keep_days = conf[CONF_PURGE_KEEP_DAYS]
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index 67d3bff3b2af..5023393dc5e2 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -299,9 +299,39 @@ class Recorder(threading.Thread):
@callback
def async_initialize(self) -> None:
"""Initialize the recorder."""
+ entity_filter = self.entity_filter
+ exclude_event_types = self.exclude_event_types
+ queue_put = self._queue.put_nowait
+ event_task = EventTask
+
+ @callback
+ def _event_listener(event: Event) -> None:
+ """Listen for new events and put them in the process queue."""
+ if event.event_type in exclude_event_types:
+ return
+
+ if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None:
+ queue_put(event_task(event))
+ return
+
+ if isinstance(entity_id, str):
+ if entity_filter(entity_id):
+ queue_put(event_task(event))
+ return
+
+ if isinstance(entity_id, list):
+ for eid in entity_id:
+ if entity_filter(eid):
+ queue_put(event_task(event))
+ return
+ return
+
+ # Unknown what it is.
+ queue_put(event_task(event))
+
self._event_listener = self.hass.bus.async_listen(
MATCH_ALL,
- self.event_listener,
+ _event_listener,
run_immediately=True,
)
self._queue_watcher = async_track_time_interval(
@@ -412,27 +442,6 @@ class Recorder(threading.Thread):
self._periodic_listener()
self._periodic_listener = None
- @callback
- def _async_event_filter(self, event: Event) -> bool:
- """Filter events."""
- if event.event_type in self.exclude_event_types:
- return False
-
- if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None:
- return True
-
- if isinstance(entity_id, str):
- return self.entity_filter(entity_id)
-
- if isinstance(entity_id, list):
- for eid in entity_id:
- if self.entity_filter(eid):
- return True
- return False
-
- # Unknown what it is.
- return True
-
async def _async_close(self, event: Event) -> None:
"""Empty the queue if its still present at close."""
@@ -1257,12 +1266,6 @@ class Recorder(threading.Thread):
_LOGGER.debug("Sending keepalive")
self.event_session.connection().scalar(select(1))
- @callback
- def event_listener(self, event: Event) -> None:
- """Listen for new events and put them in the process queue."""
- if self._async_event_filter(event):
- self.queue_task(EventTask(event))
-
async def async_block_till_done(self) -> None:
"""Async version of block_till_done."""
if self._queue.empty() and not self._event_session_has_pending_writes:
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
index 5322074c2058..393bcfa3676e 100644
--- a/homeassistant/components/recorder/history/modern.py
+++ b/homeassistant/components/recorder/history/modern.py
@@ -9,7 +9,6 @@ from typing import Any, cast
from sqlalchemy import (
CompoundSelect,
- Integer,
Select,
Subquery,
and_,
@@ -19,7 +18,6 @@ from sqlalchemy import (
select,
union_all,
)
-from sqlalchemy.dialects import postgresql
from sqlalchemy.engine.row import Row
from sqlalchemy.orm.session import Session
@@ -52,16 +50,6 @@ _FIELD_MAP = {
}
-CASTABLE_DOUBLE_TYPE = (
- # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't
- # matter because we don't use the value as its always set to NULL
- #
- # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped.
- #
- Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
-)
-
-
def _stmt_and_join_attributes(
no_attributes: bool, include_last_changed: bool
) -> Select:
@@ -79,13 +67,9 @@ def _stmt_and_join_attributes_for_start_state(
) -> Select:
"""Return the statement and if StateAttributes should be joined."""
_select = select(States.metadata_id, States.state)
- _select = _select.add_columns(
- literal(value=None).label("last_updated_ts").cast(CASTABLE_DOUBLE_TYPE)
- )
+ _select = _select.add_columns(literal(value=0).label("last_updated_ts"))
if include_last_changed:
- _select = _select.add_columns(
- literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE)
- )
+ _select = _select.add_columns(literal(value=0).label("last_changed_ts"))
if not no_attributes:
_select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES)
return _select
@@ -174,28 +158,29 @@ def _significant_states_stmt(
stmt = stmt.outerjoin(
StateAttributes, States.attributes_id == StateAttributes.attributes_id
)
- stmt = stmt.order_by(States.metadata_id, States.last_updated_ts)
if not include_start_time_state or not run_start_ts:
+ stmt = stmt.order_by(States.metadata_id, States.last_updated_ts)
return stmt
- return _select_from_subquery(
- union_all(
- _select_from_subquery(
- _get_start_time_state_stmt(
- run_start_ts,
- start_time_ts,
- single_metadata_id,
- metadata_ids,
- no_attributes,
- include_last_changed,
- ).subquery(),
+ unioned_subquery = union_all(
+ _select_from_subquery(
+ _get_start_time_state_stmt(
+ run_start_ts,
+ start_time_ts,
+ single_metadata_id,
+ metadata_ids,
no_attributes,
include_last_changed,
- ),
- _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed),
- ).subquery(),
+ ).subquery(),
+ no_attributes,
+ include_last_changed,
+ ),
+ _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed),
+ ).subquery()
+ return _select_from_subquery(
+ unioned_subquery,
no_attributes,
include_last_changed,
- )
+ ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts)
def get_significant_states_with_session(
@@ -279,6 +264,7 @@ def get_significant_states_with_session(
entity_id_to_metadata_id,
minimal_response,
compressed_state_format,
+ no_attributes=no_attributes,
)
@@ -433,6 +419,7 @@ def state_changes_during_period(
entity_ids,
entity_id_to_metadata_id,
descending=descending,
+ no_attributes=no_attributes,
),
)
@@ -528,6 +515,7 @@ def get_last_state_changes(
None,
entity_ids,
entity_id_to_metadata_id,
+ no_attributes=False,
),
)
@@ -651,6 +639,7 @@ def _sorted_states_to_dict(
minimal_response: bool = False,
compressed_state_format: bool = False,
descending: bool = False,
+ no_attributes: bool = False,
) -> MutableMapping[str, list[State | dict[str, Any]]]:
"""Convert SQL results into JSON friendly data structure.
@@ -665,7 +654,7 @@ def _sorted_states_to_dict(
"""
field_map = _FIELD_MAP
state_class: Callable[
- [Row, dict[str, dict[str, Any]], float | None, str, str, float | None],
+ [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool],
State | dict[str, Any],
]
if compressed_state_format:
@@ -716,6 +705,7 @@ def _sorted_states_to_dict(
entity_id,
db_state[state_idx],
db_state[last_updated_ts_idx],
+ False,
)
for db_state in group
)
@@ -738,6 +728,7 @@ def _sorted_states_to_dict(
entity_id,
prev_state, # type: ignore[arg-type]
first_state[last_updated_ts_idx],
+ no_attributes,
)
)
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 85190e25f4a1..33c6a516c658 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
- "sqlalchemy==2.0.12",
+ "sqlalchemy==2.0.15",
"fnv-hash-fast==0.3.1",
"psutil-home-assistant==0.0.1"
]
diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py
index 523ffdf18527..73e7798b9f5c 100644
--- a/homeassistant/components/recorder/models/state.py
+++ b/homeassistant/components/recorder/models/state.py
@@ -53,6 +53,7 @@ class LazyState(State):
entity_id: str,
state: str,
last_updated_ts: float | None,
+ no_attributes: bool,
) -> None:
"""Init the lazy state."""
self._row = row
@@ -143,14 +144,14 @@ def row_to_compressed_state(
entity_id: str,
state: str,
last_updated_ts: float | None,
+ no_attributes: bool,
) -> dict[str, Any]:
"""Convert a database row to a compressed state schema 41 and later."""
- comp_state: dict[str, Any] = {
- COMPRESSED_STATE_STATE: state,
- COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_source(
+ comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state}
+ if not no_attributes:
+ comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source(
getattr(row, "attributes", None), attr_cache
- ),
- }
+ )
row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment]
comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts
if (
diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py
index 95013de125dc..9dff59d1f593 100644
--- a/homeassistant/components/recorder/purge.py
+++ b/homeassistant/components/recorder/purge.py
@@ -245,12 +245,12 @@ def _select_state_attributes_ids_to_purge(
"""Return sets of state and attribute ids to purge."""
state_ids = set()
attributes_ids = set()
- for state in session.execute(
+ for state_id, attributes_id in session.execute(
find_states_to_purge(dt_util.utc_to_timestamp(purge_before))
).all():
- state_ids.add(state.state_id)
- if state.attributes_id:
- attributes_ids.add(state.attributes_id)
+ state_ids.add(state_id)
+ if attributes_id:
+ attributes_ids.add(attributes_id)
_LOGGER.debug(
"Selected %s state ids and %s attributes_ids to remove",
len(state_ids),
@@ -265,12 +265,12 @@ def _select_event_data_ids_to_purge(
"""Return sets of event and data ids to purge."""
event_ids = set()
data_ids = set()
- for event in session.execute(
+ for event_id, data_id in session.execute(
find_events_to_purge(dt_util.utc_to_timestamp(purge_before))
).all():
- event_ids.add(event.event_id)
- if event.data_id:
- data_ids.add(event.data_id)
+ event_ids.add(event_id)
+ if data_id:
+ data_ids.add(data_id)
_LOGGER.debug(
"Selected %s event ids and %s data_ids to remove", len(event_ids), len(data_ids)
)
@@ -412,7 +412,7 @@ def _select_statistics_runs_to_purge(
Takes care to keep the newest run.
"""
statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all()
- statistic_runs_list = [run.run_id for run in statistic_runs]
+ statistic_runs_list = [run_id for (run_id,) in statistic_runs]
# Exclude the newest statistics run
if (
last_run := session.execute(find_latest_statistics_runs_run_id()).scalar()
@@ -431,7 +431,7 @@ def _select_short_term_statistics_to_purge(
find_short_term_statistics_to_purge(purge_before)
).all()
_LOGGER.debug("Selected %s short term statistics to remove", len(statistics))
- return [statistic.id for statistic in statistics]
+ return [statistic_id for (statistic_id,) in statistics]
def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
@@ -451,10 +451,10 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
_LOGGER.debug("Selected %s state ids to remove", len(states))
state_ids = set()
attributes_ids = set()
- for state in states:
- if state_id := state.state_id:
+ for state_id, attributes_id in states:
+ if state_id:
state_ids.add(state_id)
- if attributes_id := state.attributes_id:
+ if attributes_id:
attributes_ids.add(attributes_id)
return state_ids, attributes_ids
@@ -478,13 +478,13 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge(
state_ids = set()
attributes_ids = set()
data_ids = set()
- for event in events:
- event_ids.add(event.event_id)
- if state_id := event.state_id:
+ for event_id, data_id, state_id, attributes_id in events:
+ event_ids.add(event_id)
+ if state_id:
state_ids.add(state_id)
- if attributes_id := event.attributes_id:
+ if attributes_id:
attributes_ids.add(attributes_id)
- if data_id := event.data_id:
+ if data_id:
data_ids.add(data_id)
return event_ids, state_ids, attributes_ids, data_ids
@@ -726,7 +726,7 @@ def _purge_filtered_events(
.filter(States.event_id.in_(event_ids_set))
.all()
)
- and (state_ids := {state.state_id for state in states})
+ and (state_ids := {state_id for (state_id,) in states})
):
# These are legacy states that are linked to an event that are no longer
# created but since we did not remove them when we stopped adding new ones
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index 57e572a49c7f..ee9662a21570 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -165,6 +165,7 @@ class StatisticsRow(BaseStatisticsRow, total=False):
min: float | None
max: float | None
mean: float | None
+ change: float | None
def _get_unit_class(unit: str | None) -> str | None:
@@ -218,50 +219,34 @@ def _get_statistic_to_display_unit_converter(
if display_unit == statistic_unit:
return None
- convert = converter.convert
-
- def _from_normalized_unit(val: float | None) -> float | None:
- """Return val."""
- if val is None:
- return val
- return convert(val, statistic_unit, display_unit)
-
- return _from_normalized_unit
+ return converter.converter_factory_allow_none(
+ from_unit=statistic_unit, to_unit=display_unit
+ )
def _get_display_to_statistic_unit_converter(
display_unit: str | None,
statistic_unit: str | None,
-) -> Callable[[float], float]:
+) -> Callable[[float], float] | None:
"""Prepare a converter from the display unit to the statistics unit."""
-
- def no_conversion(val: float) -> float:
- """Return val."""
- return val
-
- if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
- return no_conversion
-
- return partial(converter.convert, from_unit=display_unit, to_unit=statistic_unit)
+ if (
+ display_unit == statistic_unit
+ or (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None
+ ):
+ return None
+ return converter.converter_factory(from_unit=display_unit, to_unit=statistic_unit)
def _get_unit_converter(
from_unit: str, to_unit: str
-) -> Callable[[float | None], float | None]:
+) -> Callable[[float | None], float | None] | None:
"""Prepare a converter from a unit to another unit."""
-
- def convert_units(
- val: float | None, conv: type[BaseUnitConverter], from_unit: str, to_unit: str
- ) -> float | None:
- """Return converted val."""
- if val is None:
- return val
- return conv.convert(val, from_unit=from_unit, to_unit=to_unit)
-
for conv in STATISTIC_UNIT_TO_UNIT_CONVERTER.values():
if from_unit in conv.VALID_UNITS and to_unit in conv.VALID_UNITS:
- return partial(
- convert_units, conv=conv, from_unit=from_unit, to_unit=to_unit
+ if from_unit == to_unit:
+ return None
+ return conv.converter_factory_allow_none(
+ from_unit=from_unit, to_unit=to_unit
)
raise HomeAssistantError
@@ -980,6 +965,14 @@ def _reduce_statistics_per_week(
)
+def _find_month_end_time(timestamp: datetime) -> datetime:
+ """Return the end of the month (midnight at the first day of the next month)."""
+ # We add 4 days to the end to make sure we are in the next month
+ return (timestamp.replace(day=28) + timedelta(days=4)).replace(
+ day=1, hour=0, minute=0, second=0, microsecond=0
+ )
+
+
def reduce_month_ts_factory() -> (
tuple[
Callable[[float, float], bool],
@@ -1006,10 +999,7 @@ def reduce_month_ts_factory() -> (
start_local = _local_from_timestamp(time).replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
- # We add 4 days to the end to make sure we are in the next month
- end_local = (start_local.replace(day=28) + timedelta(days=4)).replace(
- day=1, hour=0, minute=0, second=0, microsecond=0
- )
+ end_local = _find_month_end_time(start_local)
return (
start_local.astimezone(dt_util.UTC).timestamp(),
end_local.astimezone(dt_util.UTC).timestamp(),
@@ -1488,7 +1478,9 @@ def statistic_during_period(
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
- return {key: convert(value) if convert else value for key, value in result.items()}
+ if not convert:
+ return result
+ return {key: convert(value) for key, value in result.items()}
_type_column_mapping = {
@@ -1518,6 +1510,79 @@ def _generate_select_columns_for_types_stmt(
return lambda_stmt(lambda: columns, track_on=track_on)
+def _extract_metadata_and_discard_impossible_columns(
+ metadata: dict[str, tuple[int, StatisticMetaData]],
+ types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+) -> list[int]:
+ """Extract metadata ids from metadata and discard impossible columns."""
+ metadata_ids = []
+ has_mean = False
+ has_sum = False
+ for metadata_id, stats_metadata in metadata.values():
+ metadata_ids.append(metadata_id)
+ has_mean |= stats_metadata["has_mean"]
+ has_sum |= stats_metadata["has_sum"]
+ if not has_mean:
+ types.discard("mean")
+ types.discard("min")
+ types.discard("max")
+ if not has_sum:
+ types.discard("sum")
+ types.discard("state")
+ return metadata_ids
+
+
+def _augment_result_with_change(
+ hass: HomeAssistant,
+ session: Session,
+ start_time: datetime,
+ units: dict[str, str] | None,
+ _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]],
+ table: type[Statistics | StatisticsShortTerm],
+ metadata: dict[str, tuple[int, StatisticMetaData]],
+ result: dict[str, list[StatisticsRow]],
+) -> None:
+ """Add change to the result."""
+ drop_sum = "sum" not in _types
+ prev_sums = {}
+ if tmp := _statistics_at_time(
+ session,
+ {metadata[statistic_id][0] for statistic_id in result},
+ table,
+ start_time,
+ {"sum"},
+ ):
+ _metadata = dict(metadata.values())
+ for row in tmp:
+ metadata_by_id = _metadata[row.metadata_id]
+ statistic_id = metadata_by_id["statistic_id"]
+
+ state_unit = unit = metadata_by_id["unit_of_measurement"]
+ if state := hass.states.get(statistic_id):
+ state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
+
+ if convert is not None:
+ prev_sums[statistic_id] = convert(row.sum)
+ else:
+ prev_sums[statistic_id] = row.sum
+
+ for statistic_id, rows in result.items():
+ prev_sum = prev_sums.get(statistic_id) or 0
+ for statistics_row in rows:
+ if "sum" not in statistics_row:
+ continue
+ if drop_sum:
+ _sum = statistics_row.pop("sum")
+ else:
+ _sum = statistics_row["sum"]
+ if _sum is None:
+ statistics_row["change"] = None
+ continue
+ statistics_row["change"] = _sum - prev_sum
+ prev_sum = _sum
+
+
def _statistics_during_period_with_session(
hass: HomeAssistant,
session: Session,
@@ -1526,7 +1591,7 @@ def _statistics_during_period_with_session(
statistic_ids: set[str] | None,
period: Literal["5minute", "day", "hour", "week", "month"],
units: dict[str, str] | None,
- types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]],
) -> dict[str, list[StatisticsRow]]:
"""Return statistic data points during UTC period start_time - end_time.
@@ -1537,7 +1602,6 @@ def _statistics_during_period_with_session(
# This is for backwards compatibility to avoid a breaking change
# for custom integrations that call this method.
statistic_ids = set(statistic_ids) # type: ignore[unreachable]
- metadata = None
# Fetch metadata for the given (or all) statistic_ids
metadata = get_instance(hass).statistics_meta_manager.get_many(
session, statistic_ids=statistic_ids
@@ -1545,9 +1609,46 @@ def _statistics_during_period_with_session(
if not metadata:
return {}
+ types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = set()
+ for stat_type in _types:
+ if stat_type == "change":
+ types.add("sum")
+ continue
+ types.add(stat_type)
+
metadata_ids = None
if statistic_ids is not None:
- metadata_ids = [metadata_id for metadata_id, _ in metadata.values()]
+ metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types)
+
+ # Align start_time and end_time with the period
+ if period == "day":
+ start_time = dt_util.as_local(start_time).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ start_time = start_time.replace()
+ if end_time is not None:
+ end_local = dt_util.as_local(end_time)
+ end_time = end_local.replace(
+ hour=0, minute=0, second=0, microsecond=0
+ ) + timedelta(days=1)
+ elif period == "week":
+ start_local = dt_util.as_local(start_time)
+ start_time = start_local.replace(
+ hour=0, minute=0, second=0, microsecond=0
+ ) - timedelta(days=start_local.weekday())
+ if end_time is not None:
+ end_local = dt_util.as_local(end_time)
+ end_time = (
+ end_local.replace(hour=0, minute=0, second=0, microsecond=0)
+ - timedelta(days=end_local.weekday())
+ + timedelta(days=7)
+ )
+ elif period == "month":
+ start_time = dt_util.as_local(start_time).replace(
+ day=1, hour=0, minute=0, second=0, microsecond=0
+ )
+ if end_time is not None:
+ end_time = _find_month_end_time(dt_util.as_local(end_time))
table: type[Statistics | StatisticsShortTerm] = (
Statistics if period != "5minute" else StatisticsShortTerm
@@ -1575,17 +1676,22 @@ def _statistics_during_period_with_session(
types,
)
- # Return statistics combined with metadata
- if period not in ("day", "week", "month"):
- return result
-
if period == "day":
- return _reduce_statistics_per_day(result, types)
+ result = _reduce_statistics_per_day(result, types)
if period == "week":
- return _reduce_statistics_per_week(result, types)
+ result = _reduce_statistics_per_week(result, types)
- return _reduce_statistics_per_month(result, types)
+ if period == "month":
+ result = _reduce_statistics_per_month(result, types)
+
+ if "change" in _types:
+ _augment_result_with_change(
+ hass, session, start_time, units, _types, table, metadata, result
+ )
+
+ # Return statistics combined with metadata
+ return result
def statistics_during_period(
@@ -1595,7 +1701,7 @@ def statistics_during_period(
statistic_ids: set[str] | None,
period: Literal["5minute", "day", "hour", "week", "month"],
units: dict[str, str] | None,
- types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]],
) -> dict[str, list[StatisticsRow]]:
"""Return statistic data points during UTC period start_time - end_time.
@@ -1661,7 +1767,8 @@ def _get_last_statistics(
)
if not metadata:
return {}
- metadata_id = metadata[statistic_id][0]
+ metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types)
+ metadata_id = metadata_ids[0]
if table == Statistics:
stmt = _get_last_statistics_stmt(metadata_id, number_of_stats)
else:
@@ -1753,11 +1860,7 @@ def get_latest_short_term_statistics(
)
if not metadata:
return {}
- metadata_ids = [
- metadata[statistic_id][0]
- for statistic_id in statistic_ids
- if statistic_id in metadata
- ]
+ metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types)
stmt = _latest_short_term_statistics_stmt(metadata_ids)
stats = cast(
Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False)
@@ -1867,8 +1970,6 @@ def _sorted_statistics_to_dict( # noqa: C901
assert stats, "stats must not be empty" # Guard against implementation error
result: dict[str, list[StatisticsRow]] = defaultdict(list)
metadata = dict(_metadata.values())
- need_stat_at_start_time: set[int] = set()
- start_time_ts = start_time.timestamp() if start_time else None
# Identify metadata IDs for which no data was available at the requested start time
field_map: dict[str, int] = {key: idx for idx, key in enumerate(stats[0]._fields)}
metadata_id_idx = field_map["metadata_id"]
@@ -1879,9 +1980,6 @@ def _sorted_statistics_to_dict( # noqa: C901
for meta_id, group in groupby(stats, key_func):
stats_list = stats_by_meta_id[meta_id] = list(group)
seen_statistic_ids.add(metadata[meta_id]["statistic_id"])
- first_start_time_ts = stats_list[0][start_ts_idx]
- if start_time_ts and first_start_time_ts > start_time_ts:
- need_stat_at_start_time.add(meta_id)
# Set all statistic IDs to empty lists in result set to maintain the order
if statistic_ids is not None:
@@ -1892,15 +1990,6 @@ def _sorted_statistics_to_dict( # noqa: C901
if stat_id in seen_statistic_ids:
result[stat_id] = []
- # Fetch last known statistics for the needed metadata IDs
- if need_stat_at_start_time:
- assert start_time # Can not be None if need_stat_at_start_time is not empty
- if tmp := _statistics_at_time(
- session, need_stat_at_start_time, table, start_time, types
- ):
- for stat in tmp:
- stats_by_meta_id[stat[metadata_id_idx]].insert(0, stat)
-
# Figure out which fields we need to extract from the SQL result
# and which indices they have in the result so we can avoid the overhead
# of doing a dict lookup for each row
@@ -2187,10 +2276,10 @@ def adjust_statistics(
return True
statistic_unit = metadata[statistic_id][1]["unit_of_measurement"]
- convert = _get_display_to_statistic_unit_converter(
+ if convert := _get_display_to_statistic_unit_converter(
adjustment_unit, statistic_unit
- )
- sum_adjustment = convert(sum_adjustment)
+ ):
+ sum_adjustment = convert(sum_adjustment)
_adjust_sum_statistics(
session,
@@ -2257,7 +2346,14 @@ def change_statistics_unit(
metadata_id = metadata[0]
- convert = _get_unit_converter(old_unit, new_unit)
+ if not (convert := _get_unit_converter(old_unit, new_unit)):
+ _LOGGER.warning(
+ "Statistics unit of measurement for %s is already %s",
+ statistic_id,
+ new_unit,
+ )
+ return
+
tables: tuple[type[StatisticsBase], ...] = (
Statistics,
StatisticsShortTerm,
diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py
index c52df1b25e31..733dafeba279 100644
--- a/homeassistant/components/recorder/websocket_api.py
+++ b/homeassistant/components/recorder/websocket_api.py
@@ -154,7 +154,7 @@ def _ws_get_statistics_during_period(
statistic_ids: set[str] | None,
period: Literal["5minute", "day", "hour", "week", "month"],
units: dict[str, str],
- types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]],
) -> str:
"""Fetch statistics and convert them to json in the executor."""
result = statistics_during_period(
@@ -200,7 +200,7 @@ async def ws_handle_get_statistics_during_period(
end_time = None
if (types := msg.get("types")) is None:
- types = {"last_reset", "max", "mean", "min", "state", "sum"}
+ types = {"change", "last_reset", "max", "mean", "min", "state", "sum"}
connection.send_message(
await get_instance(hass).async_add_executor_job(
_ws_get_statistics_during_period,
@@ -225,7 +225,7 @@ async def ws_handle_get_statistics_during_period(
vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"),
vol.Optional("units"): UNIT_SCHEMA,
vol.Optional("types"): vol.All(
- [vol.Any("last_reset", "max", "mean", "min", "state", "sum")],
+ [vol.Any("change", "last_reset", "max", "mean", "min", "state", "sum")],
vol.Coerce(set),
),
}
diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json
index f0d2787b6586..18a924942426 100644
--- a/homeassistant/components/remote/strings.json
+++ b/homeassistant/components/remote/strings.json
@@ -24,5 +24,11 @@
"on": "[%key:common::state::on%]"
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index 76c0963e2c06..a5050d3c436d 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -75,6 +75,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
)
+ starting = True
+
async def async_device_config_update() -> None:
"""Update the host state cache and renew the ONVIF-subscription."""
async with async_timeout.timeout(host.api.timeout):
@@ -96,9 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async with async_timeout.timeout(host.api.timeout):
try:
return await host.api.check_new_firmware()
- except ReolinkError as err:
+ except (ReolinkError, asyncio.exceptions.CancelledError) as err:
+ if starting:
+ _LOGGER.debug(
+ "Error checking Reolink firmware update at startup "
+ "from %s, possibly internet access is blocked",
+ host.api.nvr_name,
+ )
+ return False
+
raise UpdateFailed(
- f"Error checking Reolink firmware update {host.api.nvr_name}"
+ f"Error checking Reolink firmware update from {host.api.nvr_name}, "
+ "if the camera is blocked from accessing the internet, "
+ "disable the update entity"
) from err
device_coordinator = DataUpdateCoordinator(
@@ -120,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# If camera WAN blocked, firmware check fails, do not prevent setup
await asyncio.gather(
device_coordinator.async_config_entry_first_refresh(),
- firmware_coordinator.async_refresh(),
+ firmware_coordinator.async_config_entry_first_refresh(),
)
except ConfigEntryNotReady:
await host.stop()
@@ -138,6 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
config_entry.add_update_listener(entry_update_listener)
)
+ starting = False
return True
diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py
index a34f8c85d36c..b012649ec4c5 100644
--- a/homeassistant/components/reolink/camera.py
+++ b/homeassistant/components/reolink/camera.py
@@ -28,13 +28,18 @@ async def async_setup_entry(
cameras = []
for channel in host.api.stream_channels:
- streams = ["sub", "main", "snapshots"]
+ streams = ["sub", "main", "snapshots_sub", "snapshots_main"]
if host.api.protocol in ["rtmp", "flv"]:
streams.append("ext")
+ if host.api.supported(channel, "autotrack_stream"):
+ streams.extend(
+ ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"]
+ )
+
for stream in streams:
stream_url = await host.api.get_stream_source(channel, stream)
- if stream_url is None and stream != "snapshots":
+ if stream_url is None and "snapshots" not in stream:
continue
cameras.append(ReolinkCamera(reolink_data, channel, stream))
@@ -58,12 +63,16 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera):
self._stream = stream
+ stream_name = self._stream.replace("_", " ")
if self._host.api.model in DUAL_LENS_MODELS:
- self._attr_name = f"{self._stream} lens {self._channel}"
+ self._attr_name = f"{stream_name} lens {self._channel}"
else:
- self._attr_name = self._stream
- self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}"
- self._attr_entity_registry_enabled_default = stream == "sub"
+ self._attr_name = stream_name
+ stream_id = self._stream
+ if stream_id == "snapshots_main":
+ stream_id = "snapshots"
+ self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}"
+ self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"]
async def stream_source(self) -> str | None:
"""Return the source of the stream."""
@@ -73,4 +82,4 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
- return await self._host.api.get_snapshot(self._channel)
+ return await self._host.api.get_snapshot(self._channel, self._stream)
diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py
index 8dad658d7db2..c57f7b1e77e6 100644
--- a/homeassistant/components/reolink/host.py
+++ b/homeassistant/components/reolink/host.py
@@ -8,16 +8,16 @@ from typing import Any
import aiohttp
from aiohttp.web import Request
-import async_timeout
from reolink_aio.api import Host
from reolink_aio.exceptions import ReolinkError, SubscriptionError
from homeassistant.components import webhook
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
-from homeassistant.core import HomeAssistant
+from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN
@@ -26,6 +26,7 @@ from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotA
DEFAULT_TIMEOUT = 60
FIRST_ONVIF_TIMEOUT = 15
SUBSCRIPTION_RENEW_THRESHOLD = 300
+POLL_INTERVAL_NO_PUSH = 5
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +60,9 @@ class ReolinkHost:
self._base_url: str = ""
self._webhook_url: str = ""
self._webhook_reachable: asyncio.Event = asyncio.Event()
+ self._cancel_poll: CALLBACK_TYPE | None = None
+ self._cancel_onvif_check: CALLBACK_TYPE | None = None
+ self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True)
self._lost_subscription: bool = False
@property
@@ -144,13 +148,46 @@ class ReolinkHost:
await self.subscribe()
- _LOGGER.debug(
- "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url
+ if self._api.supported(None, "initial_ONVIF_state"):
+ _LOGGER.debug(
+ "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url
+ )
+ else:
+ _LOGGER.debug(
+ "Camera model %s most likely does not push its initial state"
+ "upon ONVIF subscription, do not check",
+ self._api.model,
+ )
+ self._cancel_onvif_check = async_call_later(
+ self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif
)
- try:
- async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT):
- await self._webhook_reachable.wait()
- except asyncio.TimeoutError:
+
+ if self._api.sw_version_update_required:
+ ir.async_create_issue(
+ self._hass,
+ DOMAIN,
+ "firmware_update",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="firmware_update",
+ translation_placeholders={
+ "required_firmware": self._api.sw_version_required.version_string,
+ "current_firmware": self._api.sw_version,
+ "model": self._api.model,
+ "hw_version": self._api.hardware_version,
+ "name": self._api.nvr_name,
+ "download_link": "https://reolink.com/download-center/",
+ },
+ )
+ else:
+ ir.async_delete_issue(self._hass, DOMAIN, "firmware_update")
+
+ async def _async_check_onvif(self, *_) -> None:
+ """Check the ONVIF subscription."""
+ if (
+ self._api.supported(None, "initial_ONVIF_state")
+ and not self._webhook_reachable.is_set()
+ ):
_LOGGER.debug(
"Did not receive initial ONVIF state on webhook '%s' after %i seconds",
self._webhook_url,
@@ -172,25 +209,10 @@ class ReolinkHost:
else:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
- if self._api.sw_version_update_required:
- ir.async_create_issue(
- self._hass,
- DOMAIN,
- "firmware_update",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="firmware_update",
- translation_placeholders={
- "required_firmware": self._api.sw_version_required.version_string,
- "current_firmware": self._api.sw_version,
- "model": self._api.model,
- "hw_version": self._api.hardware_version,
- "name": self._api.nvr_name,
- "download_link": "https://reolink.com/download-center/",
- },
- )
- else:
- ir.async_delete_issue(self._hass, DOMAIN, "firmware_update")
+ # If no ONVIF push is received, start fast polling
+ await self._async_poll_all_motion()
+
+ self._cancel_onvif_check = None
async def update_states(self) -> None:
"""Call the API of the camera device to update the internal states."""
@@ -228,6 +250,12 @@ class ReolinkHost:
async def stop(self, event=None):
"""Disconnect the API."""
+ if self._cancel_poll is not None:
+ self._cancel_poll()
+ self._cancel_poll = None
+ if self._cancel_onvif_check is not None:
+ self._cancel_onvif_check()
+ self._cancel_onvif_check = None
self.unregister_webhook()
await self.disconnect()
@@ -359,6 +387,43 @@ class ReolinkHost:
webhook.async_unregister(self._hass, self.webhook_id)
self.webhook_id = None
+ async def _async_poll_all_motion(self, *_) -> None:
+ """Poll motion and AI states until the first ONVIF push is received."""
+ if self._webhook_reachable.is_set():
+ # ONVIF push is working, stop polling
+ self._cancel_poll = None
+ return
+
+ try:
+ await self._api.get_motion_state_all_ch()
+ except (
+ aiohttp.ClientConnectorError,
+ ReolinkError,
+ ) as err:
+ _LOGGER.error(
+ "Reolink error while polling motion state for host %s:%s: %s",
+ self._api.host,
+ self._api.port,
+ str(err),
+ )
+ except asyncio.TimeoutError:
+ _LOGGER.error(
+ "Reolink timeout error while polling motion state for host %s:%s",
+ self._api.host,
+ self._api.port,
+ )
+ finally:
+ # schedule next poll
+ if not self._hass.is_stopping:
+ self._cancel_poll = async_call_later(
+ self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job
+ )
+
+ # After receiving the new motion states in the upstream lib,
+ # update the binary sensors with async_write_ha_state
+ # The same dispatch as for the webhook can be used
+ async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {})
+
async def handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> None:
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 6a4ae98a1546..413c106b53ea 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
- "requirements": ["reolink-aio==0.5.15"]
+ "requirements": ["reolink-aio==0.5.16"]
}
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index d02dbb8ab4d7..f208e3e4035f 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -45,7 +45,7 @@
},
"webhook_url": {
"title": "Reolink webhook URL unreachable",
- "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL."
+ "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received."
},
"enable_port": {
"title": "Reolink port not enabled",
diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py
index aa578c098d50..228972e8718f 100644
--- a/homeassistant/components/repairs/__init__.py
+++ b/homeassistant/components/repairs/__init__.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import issue_handler, websocket_api
@@ -16,6 +17,7 @@ __all__ = [
"RepairsFlow",
"RepairsFlowManager",
]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
def repairs_flow_manager(hass: HomeAssistant) -> RepairsFlowManager | None:
diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py
index 60d9a2d85044..0c1f4df60930 100644
--- a/homeassistant/components/rest/binary_sensor.py
+++ b/homeassistant/components/rest/binary_sensor.py
@@ -67,7 +67,7 @@ async def async_setup_platform(
if isinstance(rest.last_exception, ssl.SSLError):
_LOGGER.error(
"Error connecting %s failed with %s",
- conf[CONF_RESOURCE],
+ rest.url,
rest.last_exception,
)
return
diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py
index 8f1dd9373918..95086f68d70a 100644
--- a/homeassistant/components/rest/data.py
+++ b/homeassistant/components/rest/data.py
@@ -50,6 +50,11 @@ class RestData:
self.last_exception: Exception | None = None
self.headers: httpx.Headers | None = None
+ @property
+ def url(self) -> str:
+ """Get url."""
+ return self._resource
+
def set_url(self, url: str) -> None:
"""Set url."""
self._resource = url
diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py
index f1d4152b4b48..e155fe47048c 100644
--- a/homeassistant/components/rest/notify.py
+++ b/homeassistant/components/rest/notify.py
@@ -5,8 +5,7 @@ from http import HTTPStatus
import logging
from typing import Any
-import requests
-from requests.auth import AuthBase, HTTPBasicAuth, HTTPDigestAuth
+import httpx
import voluptuous as vol
from homeassistant.components.notify import (
@@ -32,6 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -72,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
_LOGGER = logging.getLogger(__name__)
-def get_service(
+async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
@@ -91,12 +91,12 @@ def get_service(
password: str | None = config.get(CONF_PASSWORD)
verify_ssl: bool = config[CONF_VERIFY_SSL]
- auth: AuthBase | None = None
+ auth: httpx.Auth | None = None
if username and password:
if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
- auth = HTTPDigestAuth(username, password)
+ auth = httpx.DigestAuth(username, password)
else:
- auth = HTTPBasicAuth(username, password)
+ auth = httpx.BasicAuth(username, password)
return RestNotificationService(
hass,
@@ -129,7 +129,7 @@ class RestNotificationService(BaseNotificationService):
target_param_name: str | None,
data: dict[str, Any] | None,
data_template: dict[str, Any] | None,
- auth: AuthBase | None,
+ auth: httpx.Auth | None,
verify_ssl: bool,
) -> None:
"""Initialize the service."""
@@ -146,7 +146,7 @@ class RestNotificationService(BaseNotificationService):
self._auth = auth
self._verify_ssl = verify_ssl
- def send_message(self, message: str = "", **kwargs: Any) -> None:
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
data = {self._message_param_name: message}
@@ -179,34 +179,32 @@ class RestNotificationService(BaseNotificationService):
if self._data_template:
data.update(_data_template_creator(self._data_template))
+ websession = get_async_client(self._hass, self._verify_ssl)
if self._method == "POST":
- response = requests.post(
+ response = await websession.post(
self._resource,
headers=self._headers,
params=self._params,
data=data,
timeout=10,
- auth=self._auth,
- verify=self._verify_ssl,
+ auth=self._auth or httpx.USE_CLIENT_DEFAULT,
)
elif self._method == "POST_JSON":
- response = requests.post(
+ response = await websession.post(
self._resource,
headers=self._headers,
params=self._params,
json=data,
timeout=10,
- auth=self._auth,
- verify=self._verify_ssl,
+ auth=self._auth or httpx.USE_CLIENT_DEFAULT,
)
else: # default GET
- response = requests.get(
+ response = await websession.get(
self._resource,
headers=self._headers,
params={**self._params, **data} if self._params else data,
timeout=10,
auth=self._auth,
- verify=self._verify_ssl,
)
if (
@@ -214,21 +212,29 @@ class RestNotificationService(BaseNotificationService):
and response.status_code < 600
):
_LOGGER.exception(
- "Server error. Response %d: %s:", response.status_code, response.reason
+ "Server error. Response %d: %s:",
+ response.status_code,
+ response.reason_phrase,
)
elif (
response.status_code >= HTTPStatus.BAD_REQUEST
and response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR
):
_LOGGER.exception(
- "Client error. Response %d: %s:", response.status_code, response.reason
+ "Client error. Response %d: %s:",
+ response.status_code,
+ response.reason_phrase,
)
elif (
response.status_code >= HTTPStatus.OK
and response.status_code < HTTPStatus.MULTIPLE_CHOICES
):
_LOGGER.debug(
- "Success. Response %d: %s:", response.status_code, response.reason
+ "Success. Response %d: %s:",
+ response.status_code,
+ response.reason_phrase,
)
else:
- _LOGGER.debug("Response %d: %s:", response.status_code, response.reason)
+ _LOGGER.debug(
+ "Response %d: %s:", response.status_code, response.reason_phrase
+ )
diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py
index ead5a5893f42..6fc0b69d1fd2 100644
--- a/homeassistant/components/rest/sensor.py
+++ b/homeassistant/components/rest/sensor.py
@@ -71,7 +71,7 @@ async def async_setup_platform(
if isinstance(rest.last_exception, ssl.SSLError):
_LOGGER.error(
"Error connecting %s failed with %s",
- conf[CONF_RESOURCE],
+ rest.url,
rest.last_exception,
)
return
diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py
index 9e016db0376a..89b6529d4838 100644
--- a/homeassistant/components/rest/switch.py
+++ b/homeassistant/components/rest/switch.py
@@ -6,8 +6,8 @@ from http import HTTPStatus
import logging
from typing import Any
-import aiohttp
import async_timeout
+import httpx
import voluptuous as vol
from homeassistant.components.switch import (
@@ -30,8 +30,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, template
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.template_entity import (
TEMPLATE_ENTITY_BASE_SCHEMA,
TemplateEntity,
@@ -89,8 +89,8 @@ async def async_setup_platform(
switch = RestSwitch(hass, config, unique_id)
req = await switch.get_device_state(hass)
- if req.status >= HTTPStatus.BAD_REQUEST:
- _LOGGER.error("Got non-ok response from resource: %s", req.status)
+ if req.status_code >= HTTPStatus.BAD_REQUEST:
+ _LOGGER.error("Got non-ok response from resource: %s", req.status_code)
else:
async_add_entities([switch])
except (TypeError, ValueError):
@@ -98,7 +98,7 @@ async def async_setup_platform(
"Missing resource or schema in configuration. "
"Add http:// or https:// to your URL"
)
- except (asyncio.TimeoutError, aiohttp.ClientError) as exc:
+ except (asyncio.TimeoutError, httpx.RequestError) as exc:
raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc
@@ -120,11 +120,11 @@ class RestSwitch(TemplateEntity, SwitchEntity):
unique_id=unique_id,
)
- auth: aiohttp.BasicAuth | None = None
+ auth: httpx.BasicAuth | None = None
username: str | None = None
if username := config.get(CONF_USERNAME):
password: str = config[CONF_PASSWORD]
- auth = aiohttp.BasicAuth(username, password=password)
+ auth = httpx.BasicAuth(username, password=password)
self._resource: str = config[CONF_RESOURCE]
self._state_resource: str = config.get(CONF_STATE_RESOURCE) or self._resource
@@ -155,13 +155,13 @@ class RestSwitch(TemplateEntity, SwitchEntity):
try:
req = await self.set_device_state(body_on_t)
- if req.status == HTTPStatus.OK:
+ if req.status_code == HTTPStatus.OK:
self._attr_is_on = True
else:
_LOGGER.error(
"Can't turn on %s. Is resource/endpoint offline?", self._resource
)
- except (asyncio.TimeoutError, aiohttp.ClientError):
+ except (asyncio.TimeoutError, httpx.RequestError):
_LOGGER.error("Error while switching on %s", self._resource)
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -170,24 +170,24 @@ class RestSwitch(TemplateEntity, SwitchEntity):
try:
req = await self.set_device_state(body_off_t)
- if req.status == HTTPStatus.OK:
+ if req.status_code == HTTPStatus.OK:
self._attr_is_on = False
else:
_LOGGER.error(
"Can't turn off %s. Is resource/endpoint offline?", self._resource
)
- except (asyncio.TimeoutError, aiohttp.ClientError):
+ except (asyncio.TimeoutError, httpx.RequestError):
_LOGGER.error("Error while switching off %s", self._resource)
- async def set_device_state(self, body: Any) -> aiohttp.ClientResponse:
+ async def set_device_state(self, body: Any) -> httpx.Response:
"""Send a state update to the device."""
- websession = async_get_clientsession(self.hass, self._verify_ssl)
+ websession = get_async_client(self.hass, self._verify_ssl)
rendered_headers = template.render_complex(self._headers, parse_result=False)
rendered_params = template.render_complex(self._params)
async with async_timeout.timeout(self._timeout):
- req: aiohttp.ClientResponse = await getattr(websession, self._method)(
+ req: httpx.Response = await getattr(websession, self._method)(
self._resource,
auth=self._auth,
data=bytes(body, "utf-8"),
@@ -202,12 +202,12 @@ class RestSwitch(TemplateEntity, SwitchEntity):
await self.get_device_state(self.hass)
except asyncio.TimeoutError:
_LOGGER.exception("Timed out while fetching data")
- except aiohttp.ClientError as err:
+ except httpx.RequestError as err:
_LOGGER.exception("Error while fetching data: %s", err)
- async def get_device_state(self, hass: HomeAssistant) -> aiohttp.ClientResponse:
+ async def get_device_state(self, hass: HomeAssistant) -> httpx.Response:
"""Get the latest data from REST API and update the state."""
- websession = async_get_clientsession(hass, self._verify_ssl)
+ websession = get_async_client(hass, self._verify_ssl)
rendered_headers = template.render_complex(self._headers, parse_result=False)
rendered_params = template.render_complex(self._params)
@@ -219,7 +219,7 @@ class RestSwitch(TemplateEntity, SwitchEntity):
headers=rendered_headers,
params=rendered_params,
)
- text = await req.text()
+ text = req.text
if self._is_on_template is not None:
text = self._is_on_template.async_render_with_possible_json_value(
diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py
index f5a0f0808dab..b563275297fd 100644
--- a/homeassistant/components/rflink/__init__.py
+++ b/homeassistant/components/rflink/__init__.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
STATE_ON,
)
-from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback
+from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
@@ -28,6 +28,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -246,7 +247,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
@callback
- def reconnect(exc=None):
+ def reconnect(_: Exception | None = None) -> None:
"""Schedule reconnect after connection has been unexpectedly lost."""
# Reset protocol binding before starting reconnect
RflinkCommand.set_rflink_protocol(None)
@@ -258,6 +259,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.warning("Disconnected from Rflink, reconnecting")
hass.async_create_task(connect())
+ _reconnect_job = HassJob(reconnect, "Rflink reconnect", cancel_on_shutdown=True)
+
async def connect():
"""Set up connection and hook it into HA for reconnect/shutdown."""
_LOGGER.info("Initiating Rflink connection")
@@ -284,7 +287,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SerialException,
OSError,
asyncio.TimeoutError,
- ) as exc:
+ ):
reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL]
_LOGGER.exception(
"Error connecting to Rflink, reconnecting in %s", reconnect_interval
@@ -292,7 +295,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Connection to Rflink device is lost, make entities unavailable
async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False)
- hass.loop.call_later(reconnect_interval, reconnect, exc)
+ async_call_later(hass, reconnect_interval, _reconnect_job)
return
# There is a valid connection to a Rflink device now so
diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py
index 2aa3bd20b8cc..2e054ce47245 100644
--- a/homeassistant/components/rfxtrx/config_flow.py
+++ b/homeassistant/components/rfxtrx/config_flow.py
@@ -2,12 +2,14 @@
from __future__ import annotations
import asyncio
+from contextlib import suppress
import copy
import itertools
import os
from typing import Any, TypedDict, cast
import RFXtrx as rfxtrxmod
+from async_timeout import timeout
import serial
import serial.tools.list_ports
import voluptuous as vol
@@ -346,34 +348,57 @@ class OptionsFlow(config_entries.OptionsFlow):
entity_migration_map[new_entity_id] = entry
@callback
- def _handle_state_change(
+ def _handle_state_removed(
entity_id: str, old_state: State | None, new_state: State | None
) -> None:
# Wait for entities to finish cleanup
- if new_state is None and entity_id in pending_entities:
- pending_entities.remove(entity_id)
- if not pending_entities:
+ if new_state is None and entity_id in entities_to_be_removed:
+ entities_to_be_removed.remove(entity_id)
+ if not entities_to_be_removed:
wait_for_entities.set()
# Create a set with entities to be removed which are currently in the state
# machine
- pending_entities = {
+ entities_to_be_removed = {
entry.entity_id
for entry in entity_migration_map.values()
if not self.hass.states.async_available(entry.entity_id)
}
wait_for_entities = asyncio.Event()
remove_track_state_changes = async_track_state_change(
- self.hass, pending_entities, _handle_state_change
+ self.hass, entities_to_be_removed, _handle_state_removed
)
for entry in entity_migration_map.values():
entity_registry.async_remove(entry.entity_id)
# Wait for entities to finish cleanup
- await wait_for_entities.wait()
+ with suppress(asyncio.TimeoutError):
+ async with timeout(10):
+ await wait_for_entities.wait()
remove_track_state_changes()
+ @callback
+ def _handle_state_added(
+ entity_id: str, old_state: State | None, new_state: State | None
+ ) -> None:
+ # Wait for entities to be added
+ if old_state is None and entity_id in entities_to_be_added:
+ entities_to_be_added.remove(entity_id)
+ if not entities_to_be_added:
+ wait_for_entities.set()
+
+ # Create a set with entities to be added to the state machine
+ entities_to_be_added = {
+ entry.entity_id
+ for entry in entity_migration_map.values()
+ if self.hass.states.async_available(entry.entity_id)
+ }
+ wait_for_entities = asyncio.Event()
+ remove_track_state_changes = async_track_state_change(
+ self.hass, entities_to_be_added, _handle_state_added
+ )
+
for entity_id, entry in entity_migration_map.items():
entity_registry.async_update_entity(
entity_id,
@@ -382,6 +407,12 @@ class OptionsFlow(config_entries.OptionsFlow):
icon=entry.icon,
)
+ # Wait for entities to finish renaming
+ with suppress(asyncio.TimeoutError):
+ async with timeout(10):
+ await wait_for_entities.wait()
+ remove_track_state_changes()
+
device_registry.async_remove_device(old_device)
def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool:
diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py
index 2badc6d4ca54..cfc16126359d 100644
--- a/homeassistant/components/rfxtrx/helpers.py
+++ b/homeassistant/components/rfxtrx/helpers.py
@@ -6,6 +6,8 @@ from RFXtrx import RFXtrxDevice, get_device
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
+from . import get_device_tuple_from_identifiers
+
@callback
def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice:
@@ -15,7 +17,9 @@ def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice
if registry_device is None:
raise ValueError(f"Device {device_id} not found")
- device_tuple = list(list(registry_device.identifiers)[0])
+ device_tuple = get_device_tuple_from_identifiers(registry_device.identifiers)
+ assert device_tuple
+
return get_device(
- int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3]
+ int(device_tuple[0], 16), int(device_tuple[1], 16), device_tuple[2]
)
diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py
index e594b47c93a5..3613a640f1ab 100644
--- a/homeassistant/components/rfxtrx/sensor.py
+++ b/homeassistant/components/rfxtrx/sensor.py
@@ -281,6 +281,7 @@ async def async_setup_entry(
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class RfxtrxSensor(RfxtrxEntity, SensorEntity):
"""Representation of a RFXtrx sensor.
diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py
index c9f10febb6b4..bfff08d5ea60 100644
--- a/homeassistant/components/rfxtrx/siren.py
+++ b/homeassistant/components/rfxtrx/siren.py
@@ -119,6 +119,11 @@ class RfxtrxOffDelayMixin(Entity):
self._timeout()
self._timeout = None
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ self._cancel_timeout()
+ return await super().async_will_remove_from_hass()
+
class RfxtrxChime(RfxtrxCommandEntity, SirenEntity, RfxtrxOffDelayMixin):
"""Representation of a RFXtrx chime."""
diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py
index 18fd30754e24..e0fac0abfcf5 100644
--- a/homeassistant/components/rituals_perfume_genie/__init__.py
+++ b/homeassistant/components/rituals_perfume_genie/__init__.py
@@ -1,18 +1,18 @@
"""The Rituals Perfume Genie integration."""
-from datetime import timedelta
-import logging
+import asyncio
import aiohttp
from pyrituals import Account, Diffuser
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN
+from .const import ACCOUNT_HASH, DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -22,10 +22,6 @@ PLATFORMS = [
Platform.SWITCH,
]
-_LOGGER = logging.getLogger(__name__)
-
-UPDATE_INTERVAL = timedelta(minutes=2)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Rituals Perfume Genie from a config entry."""
@@ -37,20 +33,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
- COORDINATORS: {},
- DEVICES: {},
+ # Migrate old unique_ids to the new format
+ async_migrate_entities_unique_ids(hass, entry, account_devices)
+
+ # Create a coordinator for each diffuser
+ coordinators = {
+ diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser)
+ for diffuser in account_devices
}
- for device in account_devices:
- hublot = device.hublot
-
- coordinator = RitualsDataUpdateCoordinator(hass, device)
- await coordinator.async_config_entry_first_refresh()
-
- hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device
- hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator
+ # Refresh all coordinators
+ await asyncio.gather(
+ *[
+ coordinator.async_config_entry_first_refresh()
+ for coordinator in coordinators.values()
+ ]
+ )
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -65,19 +65,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
-class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
- """Class to manage fetching Rituals Perfume Genie device data from single endpoint."""
+@callback
+def async_migrate_entities_unique_ids(
+ hass: HomeAssistant, config_entry: ConfigEntry, diffusers: list[Diffuser]
+) -> None:
+ """Migrate unique_ids in the entity registry to the new format."""
+ entity_registry = er.async_get(hass)
+ registry_entries = er.async_entries_for_config_entry(
+ entity_registry, config_entry.entry_id
+ )
- def __init__(self, hass: HomeAssistant, device: Diffuser) -> None:
- """Initialize global Rituals Perfume Genie data updater."""
- self._device = device
- super().__init__(
- hass,
- _LOGGER,
- name=f"{DOMAIN}-{device.hublot}",
- update_interval=UPDATE_INTERVAL,
- )
+ conversion: dict[tuple[str, str], str] = {
+ (Platform.BINARY_SENSOR, " Battery Charging"): "charging",
+ (Platform.NUMBER, " Perfume Amount"): "perfume_amount",
+ (Platform.SELECT, " Room Size"): "room_size_square_meter",
+ (Platform.SENSOR, " Battery"): "battery_percentage",
+ (Platform.SENSOR, " Fill"): "fill",
+ (Platform.SENSOR, " Perfume"): "perfume",
+ (Platform.SENSOR, " Wifi"): "wifi_percentage",
+ (Platform.SWITCH, ""): "is_on",
+ }
- async def _async_update_data(self) -> None:
- """Fetch data from Rituals."""
- await self._device.update_data()
+ for diffuser in diffusers:
+ for registry_entry in registry_entries:
+ if new_unique_id := conversion.get(
+ (
+ registry_entry.domain,
+ registry_entry.unique_id.removeprefix(diffuser.hublot),
+ )
+ ):
+ entity_registry.async_update_entity(
+ registry_entry.entity_id,
+ new_unique_id=f"{diffuser.hublot}-{new_unique_id}",
+ )
diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py
index 5e81ecb5cae4..19732169b86a 100644
--- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py
+++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py
@@ -1,22 +1,51 @@
"""Support for Rituals Perfume Genie binary sensors."""
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
from pyrituals import Diffuser
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
+ BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import RitualsDataUpdateCoordinator
-from .const import COORDINATORS, DEVICES, DOMAIN
+from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
from .entity import DiffuserEntity
-CHARGING_SUFFIX = " Battery Charging"
+
+@dataclass
+class RitualsentityDescriptionMixin:
+ """Mixin values for Rituals entities."""
+
+ is_on_fn: Callable[[Diffuser], bool]
+ has_fn: Callable[[Diffuser], bool]
+
+
+@dataclass
+class RitualsBinarySensorEntityDescription(
+ BinarySensorEntityDescription, RitualsentityDescriptionMixin
+):
+ """Class describing Rituals binary sensor entities."""
+
+
+ENTITY_DESCRIPTIONS = (
+ RitualsBinarySensorEntityDescription(
+ key="charging",
+ translation_key="charging",
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda diffuser: diffuser.charging,
+ has_fn=lambda diffuser: diffuser.has_battery,
+ ),
+)
async def async_setup_entry(
@@ -25,29 +54,24 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the diffuser binary sensors."""
- diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES]
- coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]
+ coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
async_add_entities(
- DiffuserBatteryChargingBinarySensor(diffuser, coordinators[hublot])
- for hublot, diffuser in diffusers.items()
- if diffuser.has_battery
+ RitualsBinarySensorEntity(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in ENTITY_DESCRIPTIONS
+ if description.has_fn(coordinator.diffuser)
)
-class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity):
- """Representation of a diffuser battery charging binary sensor."""
+class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity):
+ """Defines a Rituals binary sensor entity."""
- _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
- _attr_entity_category = EntityCategory.DIAGNOSTIC
-
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the battery charging binary sensor."""
- super().__init__(diffuser, coordinator, CHARGING_SUFFIX)
+ entity_description: RitualsBinarySensorEntityDescription
@property
def is_on(self) -> bool:
- """Return the state of the battery charging binary sensor."""
- return self._diffuser.charging
+ """Return the state of the binary sensor."""
+ return self.entity_description.is_on_fn(self.coordinator.diffuser)
diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py
index 21c570ffb935..35d1c32d3063 100644
--- a/homeassistant/components/rituals_perfume_genie/const.py
+++ b/homeassistant/components/rituals_perfume_genie/const.py
@@ -1,7 +1,9 @@
"""Constants for the Rituals Perfume Genie integration."""
+
+from datetime import timedelta
+
DOMAIN = "rituals_perfume_genie"
ACCOUNT_HASH = "account_hash"
-COORDINATORS = "coordinators"
-DEVICES = "devices"
+UPDATE_INTERVAL = timedelta(minutes=2)
diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py
new file mode 100644
index 000000000000..b63b28e4de9d
--- /dev/null
+++ b/homeassistant/components/rituals_perfume_genie/coordinator.py
@@ -0,0 +1,29 @@
+"""The Rituals Perfume Genie data update coordinator."""
+import logging
+
+from pyrituals import Diffuser
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, UPDATE_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Class to manage fetching Rituals Perfume Genie device data from single endpoint."""
+
+ def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None:
+ """Initialize global Rituals Perfume Genie data updater."""
+ self.diffuser = diffuser
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN}-{diffuser.hublot}",
+ update_interval=UPDATE_INTERVAL,
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from Rituals."""
+ await self.diffuser.update_data()
diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py
index e3bf1ef4e634..713c3905f05c 100644
--- a/homeassistant/components/rituals_perfume_genie/entity.py
+++ b/homeassistant/components/rituals_perfume_genie/entity.py
@@ -1,13 +1,11 @@
"""Base class for Rituals Perfume Genie diffuser entity."""
from __future__ import annotations
-from pyrituals import Diffuser
-
-from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import RitualsDataUpdateCoordinator
from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
MANUFACTURER = "Rituals Cosmetics"
MODEL = "The Perfume Genie"
@@ -17,30 +15,26 @@ MODEL2 = "The Perfume Genie 2.0"
class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]):
"""Representation of a diffuser entity."""
+ _attr_has_entity_name = True
+
def __init__(
self,
- diffuser: Diffuser,
coordinator: RitualsDataUpdateCoordinator,
- entity_suffix: str,
+ description: EntityDescription,
) -> None:
"""Init from config, hookup diffuser and coordinator."""
super().__init__(coordinator)
- self._diffuser = diffuser
-
- hublot = self._diffuser.hublot
- hubname = self._diffuser.name
-
- self._attr_name = f"{hubname}{entity_suffix}"
- self._attr_unique_id = f"{hublot}{entity_suffix}"
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, hublot)},
+ identifiers={(DOMAIN, coordinator.diffuser.hublot)},
manufacturer=MANUFACTURER,
- model=MODEL if diffuser.has_battery else MODEL2,
- name=hubname,
- sw_version=diffuser.version,
+ model=MODEL if coordinator.diffuser.has_battery else MODEL2,
+ name=coordinator.diffuser.name,
+ sw_version=coordinator.diffuser.version,
)
@property
def available(self) -> bool:
"""Return if the entity is available."""
- return super().available and self._diffuser.is_online
+ return super().available and self.coordinator.diffuser.is_online
diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json
index 3483fd9d0a95..996dd1faecf9 100644
--- a/homeassistant/components/rituals_perfume_genie/manifest.json
+++ b/homeassistant/components/rituals_perfume_genie/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "rituals_perfume_genie",
"name": "Rituals Perfume Genie",
- "codeowners": ["@milanmeu"],
+ "codeowners": ["@milanmeu", "@frenck"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"iot_class": "cloud_polling",
diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py
index 8fe0a663e379..3e6af33315f6 100644
--- a/homeassistant/components/rituals_perfume_genie/number.py
+++ b/homeassistant/components/rituals_perfume_genie/number.py
@@ -1,21 +1,48 @@
"""Support for Rituals Perfume Genie numbers."""
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
from pyrituals import Diffuser
-from homeassistant.components.number import NumberEntity
+from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import RitualsDataUpdateCoordinator
-from .const import COORDINATORS, DEVICES, DOMAIN
+from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
from .entity import DiffuserEntity
-MIN_PERFUME_AMOUNT = 1
-MAX_PERFUME_AMOUNT = 3
-PERFUME_AMOUNT_SUFFIX = " Perfume Amount"
+@dataclass
+class RitualsNumberEntityDescriptionMixin:
+ """Mixin for required keys."""
+
+ value_fn: Callable[[Diffuser], int]
+ set_value_fn: Callable[[Diffuser, int], Awaitable[Any]]
+
+
+@dataclass
+class RitualsNumberEntityDescription(
+ NumberEntityDescription, RitualsNumberEntityDescriptionMixin
+):
+ """Class describing Rituals number entities."""
+
+
+ENTITY_DESCRIPTIONS = (
+ RitualsNumberEntityDescription(
+ key="perfume_amount",
+ translation_key="perfume_amount",
+ icon="mdi:gauge",
+ native_min_value=1,
+ native_max_value=3,
+ value_fn=lambda diffuser: diffuser.perfume_amount,
+ set_value_fn=lambda diffuser, value: diffuser.set_perfume_amount(value),
+ ),
+)
async def async_setup_entry(
@@ -24,39 +51,30 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the diffuser numbers."""
- diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES]
- coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]
- entities: list[DiffuserEntity] = []
- for hublot, diffuser in diffusers.items():
- coordinator = coordinators[hublot]
- entities.append(DiffuserPerfumeAmount(diffuser, coordinator))
-
- async_add_entities(entities)
+ coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
+ async_add_entities(
+ RitualsNumberEntity(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in ENTITY_DESCRIPTIONS
+ )
-class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity):
- """Representation of a diffuser perfume amount number."""
+class RitualsNumberEntity(DiffuserEntity, NumberEntity):
+ """Representation of a diffuser number entity."""
- _attr_icon = "mdi:gauge"
- _attr_native_max_value = MAX_PERFUME_AMOUNT
- _attr_native_min_value = MIN_PERFUME_AMOUNT
-
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the diffuser perfume amount number."""
- super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX)
+ entity_description: RitualsNumberEntityDescription
@property
def native_value(self) -> int:
- """Return the current perfume amount."""
- return self._diffuser.perfume_amount
+ """Return the number value."""
+ return self.entity_description.value_fn(self.coordinator.diffuser)
async def async_set_native_value(self, value: float) -> None:
- """Set the perfume amount."""
+ """Change to new number value."""
if not value.is_integer():
- raise ValueError(
- f"Can't set the perfume amount to {value}. Perfume amount must be an"
- " integer."
- )
- await self._diffuser.set_perfume_amount(int(value))
+ raise ValueError(f"Can't set value to {value}. Value must be an integer.")
+ await self.entity_description.set_value_fn(
+ self.coordinator.diffuser, int(value)
+ )
diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py
index 918ec7022c42..42e18624d13b 100644
--- a/homeassistant/components/rituals_perfume_genie/select.py
+++ b/homeassistant/components/rituals_perfume_genie/select.py
@@ -1,19 +1,51 @@
"""Support for Rituals Perfume Genie numbers."""
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
from pyrituals import Diffuser
-from homeassistant.components.select import SelectEntity
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import AREA_SQUARE_METERS, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import RitualsDataUpdateCoordinator
-from .const import COORDINATORS, DEVICES, DOMAIN
+from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
from .entity import DiffuserEntity
-ROOM_SIZE_SUFFIX = " Room Size"
+
+@dataclass
+class RitualsEntityDescriptionMixin:
+ """Mixin for required keys."""
+
+ current_fn: Callable[[Diffuser], str]
+ select_fn: Callable[[Diffuser, str], Awaitable[None]]
+
+
+@dataclass
+class RitualsSelectEntityDescription(
+ SelectEntityDescription, RitualsEntityDescriptionMixin
+):
+ """Class describing Rituals select entities."""
+
+
+ENTITY_DESCRIPTIONS = (
+ RitualsSelectEntityDescription(
+ key="room_size_square_meter",
+ translation_key="room_size_square_meter",
+ icon="mdi:ruler-square",
+ unit_of_measurement=AREA_SQUARE_METERS,
+ entity_category=EntityCategory.CONFIG,
+ options=["15", "30", "60", "100"],
+ current_fn=lambda diffuser: str(diffuser.room_size_square_meter),
+ select_fn=lambda diffuser, value: (
+ diffuser.set_room_size_square_meter(int(value))
+ ),
+ ),
+)
async def async_setup_entry(
@@ -22,34 +54,38 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the diffuser select entities."""
- diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES]
- coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]
+ coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
+
async_add_entities(
- DiffuserRoomSize(diffuser, coordinators[hublot])
- for hublot, diffuser in diffusers.items()
+ RitualsSelectEntity(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in ENTITY_DESCRIPTIONS
)
-class DiffuserRoomSize(DiffuserEntity, SelectEntity):
- """Representation of a diffuser room size select entity."""
+class RitualsSelectEntity(DiffuserEntity, SelectEntity):
+ """Representation of a diffuser select entity."""
- _attr_icon = "mdi:ruler-square"
- _attr_unit_of_measurement = AREA_SQUARE_METERS
- _attr_options = ["15", "30", "60", "100"]
- _attr_entity_category = EntityCategory.CONFIG
+ entity_description: RitualsSelectEntityDescription
def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
+ self,
+ coordinator: RitualsDataUpdateCoordinator,
+ description: RitualsSelectEntityDescription,
) -> None:
"""Initialize the diffuser room size select entity."""
- super().__init__(diffuser, coordinator, ROOM_SIZE_SUFFIX)
- self._attr_entity_registry_enabled_default = diffuser.has_battery
+ super().__init__(coordinator, description)
+ self._attr_entity_registry_enabled_default = (
+ self.coordinator.diffuser.has_battery
+ )
@property
def current_option(self) -> str:
- """Return the diffuser room size."""
- return str(self._diffuser.room_size_square_meter)
+ """Return the selected entity option to represent the entity state."""
+ return self.entity_description.current_fn(self.coordinator.diffuser)
async def async_select_option(self, option: str) -> None:
- """Change the diffuser room size."""
- await self._diffuser.set_room_size_square_meter(int(option))
+ """Change the selected option."""
+ await self.entity_description.select_fn(self.coordinator.diffuser, option)
diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py
index 04102e8abccf..944efb215366 100644
--- a/homeassistant/components/rituals_perfume_genie/sensor.py
+++ b/homeassistant/components/rituals_perfume_genie/sensor.py
@@ -1,22 +1,71 @@
"""Support for Rituals Perfume Genie sensors."""
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
from pyrituals import Diffuser
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import RitualsDataUpdateCoordinator
-from .const import COORDINATORS, DEVICES, DOMAIN
+from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
from .entity import DiffuserEntity
-BATTERY_SUFFIX = " Battery"
-PERFUME_SUFFIX = " Perfume"
-FILL_SUFFIX = " Fill"
-WIFI_SUFFIX = " Wifi"
+
+@dataclass
+class RitualsEntityDescriptionMixin:
+ """Mixin values for Rituals entities."""
+
+ value_fn: Callable[[Diffuser], int | str]
+
+
+@dataclass
+class RitualsSensorEntityDescription(
+ SensorEntityDescription, RitualsEntityDescriptionMixin
+):
+ """Class describing Rituals sensor entities."""
+
+ has_fn: Callable[[Diffuser], bool] = lambda _: True
+
+
+ENTITY_DESCRIPTIONS = (
+ RitualsSensorEntityDescription(
+ key="battery_percentage",
+ translation_key="battery_percentage",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ value_fn=lambda diffuser: diffuser.battery_percentage,
+ has_fn=lambda diffuser: diffuser.has_battery,
+ ),
+ RitualsSensorEntityDescription(
+ key="fill",
+ translation_key="fill",
+ icon="mdi:beaker",
+ value_fn=lambda diffuser: diffuser.fill,
+ ),
+ RitualsSensorEntityDescription(
+ key="perfume",
+ translation_key="perfume",
+ icon="mdi:tag",
+ value_fn=lambda diffuser: diffuser.perfume,
+ ),
+ RitualsSensorEntityDescription(
+ key="wifi_percentage",
+ translation_key="wifi_percentage",
+ icon="mdi:wifi",
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda diffuser: diffuser.wifi_percentage,
+ ),
+)
async def async_setup_entry(
@@ -25,96 +74,25 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the diffuser sensors."""
- diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES]
- coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]
- entities: list[DiffuserEntity] = []
- for hublot, diffuser in diffusers.items():
- coordinator = coordinators[hublot]
- entities.append(DiffuserPerfumeSensor(diffuser, coordinator))
- entities.append(DiffuserFillSensor(diffuser, coordinator))
- entities.append(DiffuserWifiSensor(diffuser, coordinator))
- if diffuser.has_battery:
- entities.append(DiffuserBatterySensor(diffuser, coordinator))
+ coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
- async_add_entities(entities)
+ async_add_entities(
+ RitualsSensorEntity(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in ENTITY_DESCRIPTIONS
+ if description.has_fn(coordinator.diffuser)
+ )
-class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity):
- """Representation of a diffuser perfume sensor."""
+class RitualsSensorEntity(DiffuserEntity, SensorEntity):
+ """Representation of a diffuser sensor."""
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the perfume sensor."""
- super().__init__(diffuser, coordinator, PERFUME_SUFFIX)
-
- @property
- def icon(self) -> str:
- """Return the perfume sensor icon."""
- if self._diffuser.has_cartridge:
- return "mdi:tag-text"
- return "mdi:tag-remove"
-
- @property
- def native_value(self) -> str:
- """Return the state of the perfume sensor."""
- return self._diffuser.perfume
-
-
-class DiffuserFillSensor(DiffuserEntity, SensorEntity):
- """Representation of a diffuser fill sensor."""
-
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the fill sensor."""
- super().__init__(diffuser, coordinator, FILL_SUFFIX)
-
- @property
- def icon(self) -> str:
- """Return the fill sensor icon."""
- if self._diffuser.has_cartridge:
- return "mdi:beaker"
- return "mdi:beaker-question"
-
- @property
- def native_value(self) -> str:
- """Return the state of the fill sensor."""
- return self._diffuser.fill
-
-
-class DiffuserBatterySensor(DiffuserEntity, SensorEntity):
- """Representation of a diffuser battery sensor."""
-
- _attr_device_class = SensorDeviceClass.BATTERY
- _attr_native_unit_of_measurement = PERCENTAGE
+ entity_description: RitualsSensorEntityDescription
_attr_entity_category = EntityCategory.DIAGNOSTIC
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the battery sensor."""
- super().__init__(diffuser, coordinator, BATTERY_SUFFIX)
-
@property
- def native_value(self) -> int:
- """Return the state of the battery sensor."""
- return self._diffuser.battery_percentage
-
-
-class DiffuserWifiSensor(DiffuserEntity, SensorEntity):
- """Representation of a diffuser wifi sensor."""
-
- _attr_native_unit_of_measurement = PERCENTAGE
- _attr_entity_category = EntityCategory.DIAGNOSTIC
-
- def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
- ) -> None:
- """Initialize the wifi sensor."""
- super().__init__(diffuser, coordinator, WIFI_SUFFIX)
-
- @property
- def native_value(self) -> int:
- """Return the state of the wifi sensor."""
- return self._diffuser.wifi_percentage
+ def native_value(self) -> str | int:
+ """Return the sensor value."""
+ return self.entity_description.value_fn(self.coordinator.diffuser)
diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json
index 8824923c3138..f4570dd4cfc3 100644
--- a/homeassistant/components/rituals_perfume_genie/strings.json
+++ b/homeassistant/components/rituals_perfume_genie/strings.json
@@ -17,5 +17,36 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "charging": {
+ "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]"
+ }
+ },
+ "number": {
+ "perfume_amount": {
+ "name": "Perfume amount"
+ }
+ },
+ "select": {
+ "room_size_square_meter": {
+ "name": "Room size"
+ }
+ },
+ "sensor": {
+ "battery_percentage": {
+ "name": "[%key:component::sensor::entity_component::battery::name%]"
+ },
+ "fill": {
+ "name": "Fill"
+ },
+ "perfume": {
+ "name": "Perfume"
+ },
+ "wifi_percentage": {
+ "name": "Wi-Fi signal"
+ }
+ }
}
}
diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py
index a213db4e5db8..a6083e514302 100644
--- a/homeassistant/components/rituals_perfume_genie/switch.py
+++ b/homeassistant/components/rituals_perfume_genie/switch.py
@@ -1,62 +1,94 @@
"""Support for Rituals Perfume Genie switches."""
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
from typing import Any
from pyrituals import Diffuser
-from homeassistant.components.switch import SwitchEntity
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import RitualsDataUpdateCoordinator
-from .const import COORDINATORS, DEVICES, DOMAIN
+from .const import DOMAIN
+from .coordinator import RitualsDataUpdateCoordinator
from .entity import DiffuserEntity
+@dataclass
+class RitualsEntityDescriptionMixin:
+ """Mixin values for Rituals entities."""
+
+ is_on_fn: Callable[[Diffuser], bool]
+ turn_on_fn: Callable[[Diffuser], Awaitable[None]]
+ turn_off_fn: Callable[[Diffuser], Awaitable[None]]
+
+
+@dataclass
+class RitualsSwitchEntityDescription(
+ SwitchEntityDescription, RitualsEntityDescriptionMixin
+):
+ """Class describing Rituals switch entities."""
+
+
+ENTITY_DESCRIPTIONS = (
+ RitualsSwitchEntityDescription(
+ key="is_on",
+ icon="mdi:fan",
+ is_on_fn=lambda diffuser: diffuser.is_on,
+ turn_on_fn=lambda diffuser: diffuser.turn_on(),
+ turn_off_fn=lambda diffuser: diffuser.turn_off(),
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the diffuser switch."""
- diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES]
- coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]
- entities = []
- for hublot, diffuser in diffusers.items():
- coordinator = coordinators[hublot]
- entities.append(DiffuserSwitch(diffuser, coordinator))
+ coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
- async_add_entities(entities)
+ async_add_entities(
+ RitualsSwitchEntity(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in ENTITY_DESCRIPTIONS
+ )
-class DiffuserSwitch(DiffuserEntity, SwitchEntity):
+class RitualsSwitchEntity(DiffuserEntity, SwitchEntity):
"""Representation of a diffuser switch."""
- _attr_icon = "mdi:fan"
+ entity_description: RitualsSwitchEntityDescription
def __init__(
- self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator
+ self,
+ coordinator: RitualsDataUpdateCoordinator,
+ description: RitualsSwitchEntityDescription,
) -> None:
"""Initialize the diffuser switch."""
- super().__init__(diffuser, coordinator, "")
- self._attr_is_on = self._diffuser.is_on
+ super().__init__(coordinator, description)
+ self._attr_is_on = description.is_on_fn(coordinator.diffuser)
async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn the device on."""
- await self._diffuser.turn_on()
+ """Turn the switch on."""
+ await self.entity_description.turn_on_fn(self.coordinator.diffuser)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn the device off."""
- await self._diffuser.turn_off()
+ """Turn the switch off."""
+ await self.entity_description.turn_off_fn(self.coordinator.diffuser)
self._attr_is_on = False
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
- self._attr_is_on = self._diffuser.is_on
- self.async_write_ha_state()
+ self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.diffuser)
+ super()._handle_coordinator_update()
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index 1ea5e4734bb6..1a308f9dff94 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -7,8 +7,7 @@ import logging
from roborock.api import RoborockApiClient
from roborock.cloud_api import RoborockMqttClient
-from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData
-from roborock.exceptions import RoborockException
+from roborock.containers import DeviceData, HomeDataDevice, UserData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
@@ -32,39 +31,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Getting home data")
home_data = await api_client.get_home_data(user_data)
_LOGGER.debug("Got home data %s", home_data)
- devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices
+ device_map: dict[str, HomeDataDevice] = {
+ device.duid: device for device in home_data.devices + home_data.received_devices
+ }
+ product_info = {product.id: product for product in home_data.products}
# Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map.
- mqtt_client = RoborockMqttClient(
- user_data, {device.duid: RoborockDeviceInfo(device) for device in devices}
- )
+ mqtt_clients = [
+ RoborockMqttClient(
+ user_data, DeviceData(device, product_info[device.product_id].model)
+ )
+ for device in device_map.values()
+ ]
network_results = await asyncio.gather(
- *(mqtt_client.get_networking(device.duid) for device in devices)
+ *(mqtt_client.get_networking() for mqtt_client in mqtt_clients)
)
network_info = {
device.duid: result
- for device, result in zip(devices, network_results)
+ for device, result in zip(device_map.values(), network_results)
if result is not None
}
- try:
- await mqtt_client.async_disconnect()
- except RoborockException as err:
- _LOGGER.warning("Failed disconnecting from the mqtt server %s", err)
+ await asyncio.gather(
+ *(mqtt_client.async_disconnect() for mqtt_client in mqtt_clients),
+ return_exceptions=True,
+ )
if not network_info:
raise ConfigEntryNotReady(
"Could not get network information about your devices"
)
-
- product_info = {product.id: product for product in home_data.products}
- coordinator = RoborockDataUpdateCoordinator(
- hass,
- devices,
- network_info,
- product_info,
+ coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {}
+ for device_id, device in device_map.items():
+ coordinator_map[device_id] = RoborockDataUpdateCoordinator(
+ hass,
+ device,
+ network_info[device_id],
+ product_info[device.product_id],
+ )
+ # If one device update fails - we still want to set up other devices
+ await asyncio.gather(
+ *(
+ coordinator.async_config_entry_first_refresh()
+ for coordinator in coordinator_map.values()
+ ),
+ return_exceptions=True,
)
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
+ device_id: coordinator
+ for device_id, coordinator in coordinator_map.items()
+ if coordinator.last_update_success
+ } # Only add coordinators that succeeded
- await coordinator.async_config_entry_first_refresh()
-
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ if not hass.data[DOMAIN][entry.entry_id]:
+ # Don't start if no coordinators succeeded.
+ raise ConfigEntryNotReady("There are no devices that can currently be reached.")
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -75,7 +93,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
- await hass.data[DOMAIN][entry.entry_id].release()
+ await asyncio.gather(
+ *(
+ coordinator.release()
+ for coordinator in hass.data[DOMAIN][entry.entry_id].values()
+ )
+ )
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py
index 61a9a70dd207..287229c9fd1d 100644
--- a/homeassistant/components/roborock/const.py
+++ b/homeassistant/components/roborock/const.py
@@ -6,4 +6,4 @@ CONF_ENTRY_CODE = "code"
CONF_BASE_URL = "base_url"
CONF_USER_DATA = "user_data"
-PLATFORMS = [Platform.VACUUM, Platform.SELECT]
+PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH]
diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py
index 433b46d2899d..ba9571a95f5d 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -1,21 +1,16 @@
"""Roborock Coordinator."""
from __future__ import annotations
-import asyncio
from datetime import timedelta
import logging
-from roborock.containers import (
- HomeDataDevice,
- HomeDataProduct,
- NetworkInfo,
- RoborockLocalDeviceInfo,
-)
+from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.exceptions import RoborockException
from roborock.local_api import RoborockLocalClient
-from roborock.typing import DeviceProp
+from roborock.roborock_typing import DeviceProp
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -26,61 +21,51 @@ SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
-class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]):
+class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
- devices: list[HomeDataDevice],
- devices_networking: dict[str, NetworkInfo],
- product_info: dict[str, HomeDataProduct],
+ device: HomeDataDevice,
+ device_networking: NetworkInfo,
+ product_info: HomeDataProduct,
) -> None:
"""Initialize."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
- local_devices_info: dict[str, RoborockLocalDeviceInfo] = {}
- hass_devices_info: dict[str, RoborockHassDeviceInfo] = {}
- for device in devices:
- if not (networking := devices_networking.get(device.duid)):
- _LOGGER.warning("Device %s is offline and cannot be setup", device.duid)
- continue
- hass_devices_info[device.duid] = RoborockHassDeviceInfo(
- device,
- networking,
- product_info[device.product_id],
- DeviceProp(),
- )
- local_devices_info[device.duid] = RoborockLocalDeviceInfo(
- device, networking
- )
- self.api = RoborockLocalClient(local_devices_info)
- self.devices_info = hass_devices_info
+ self.roborock_device_info = RoborockHassDeviceInfo(
+ device,
+ device_networking,
+ product_info,
+ DeviceProp(),
+ )
+ device_data = DeviceData(device, product_info.model, device_networking.ip)
+ self.api = RoborockLocalClient(device_data)
+ self.device_info = DeviceInfo(
+ name=self.roborock_device_info.device.name,
+ identifiers={(DOMAIN, self.roborock_device_info.device.duid)},
+ manufacturer="Roborock",
+ model=self.roborock_device_info.product.model,
+ sw_version=self.roborock_device_info.device.fv,
+ )
async def release(self) -> None:
"""Disconnect from API."""
await self.api.async_disconnect()
- async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None:
+ async def _update_device_prop(self) -> None:
"""Update device properties."""
- device_prop = await self.api.get_prop(device_info.device.duid)
+ device_prop = await self.api.get_prop()
if device_prop:
- if device_info.props:
- device_info.props.update(device_prop)
+ if self.roborock_device_info.props:
+ self.roborock_device_info.props.update(device_prop)
else:
- device_info.props = device_prop
+ self.roborock_device_info.props = device_prop
- async def _async_update_data(self) -> dict[str, DeviceProp]:
+ async def _async_update_data(self) -> DeviceProp:
"""Update data via library."""
try:
- await asyncio.gather(
- *(
- self._update_device_prop(device_info)
- for device_info in self.devices_info.values()
- )
- )
+ await self._update_device_prop()
except RoborockException as ex:
raise UpdateFailed(ex) from ex
- return {
- device_id: device_info.props
- for device_id, device_info in self.devices_info.items()
- }
+ return self.roborock_device_info.props
diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py
index e544147e9b8e..41db2cc08ac7 100644
--- a/homeassistant/components/roborock/device.py
+++ b/homeassistant/components/roborock/device.py
@@ -3,17 +3,47 @@
from typing import Any
from roborock.containers import Status
-from roborock.typing import RoborockCommand
+from roborock.exceptions import RoborockException
+from roborock.local_api import RoborockLocalClient
+from roborock.roborock_typing import RoborockCommand
-from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import RoborockDataUpdateCoordinator
-from .const import DOMAIN
-from .models import RoborockHassDeviceInfo
-class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]):
+class RoborockEntity(Entity):
+ """Representation of a base Roborock Entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, unique_id: str, device_info: DeviceInfo, api: RoborockLocalClient
+ ) -> None:
+ """Initialize the coordinated Roborock Device."""
+ self._attr_unique_id = unique_id
+ self._attr_device_info = device_info
+ self._api = api
+
+ async def send(
+ self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None
+ ) -> dict:
+ """Send a command to a vacuum cleaner."""
+ try:
+ response = await self._api.send_command(command, params)
+ except RoborockException as err:
+ raise HomeAssistantError(
+ f"Error while calling {command.name} with {params}"
+ ) from err
+
+ return response
+
+
+class RoborockCoordinatedEntity(
+ RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator]
+):
"""Representation of a base a coordinated Roborock Entity."""
_attr_has_entity_name = True
@@ -21,46 +51,24 @@ class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]
def __init__(
self,
unique_id: str,
- device_info: RoborockHassDeviceInfo,
coordinator: RoborockDataUpdateCoordinator,
) -> None:
"""Initialize the coordinated Roborock Device."""
- super().__init__(coordinator)
+ RoborockEntity.__init__(
+ self,
+ unique_id=unique_id,
+ device_info=coordinator.device_info,
+ api=coordinator.api,
+ )
+ CoordinatorEntity.__init__(self, coordinator=coordinator)
self._attr_unique_id = unique_id
- self._device_name = device_info.device.name
- self._device_id = device_info.device.duid
- self._device_model = device_info.product.model
- self._fw_version = device_info.device.fv
@property
def _device_status(self) -> Status:
"""Return the status of the device."""
data = self.coordinator.data
if data:
- device_data = data.get(self._device_id)
- if device_data:
- status = device_data.status
- if status:
- return status
+ status = data.status
+ if status:
+ return status
return Status({})
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return the device info."""
- return DeviceInfo(
- name=self._device_name,
- identifiers={(DOMAIN, self._device_id)},
- manufacturer="Roborock",
- model=self._device_model,
- sw_version=self._fw_version,
- )
-
- async def send(
- self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None
- ) -> dict:
- """Send a command to a vacuum cleaner."""
- response = await self.coordinator.api.send_command(
- self._device_id, command, params
- )
- await self.coordinator.async_request_refresh()
- return response
diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py
new file mode 100644
index 000000000000..e5fcc8342677
--- /dev/null
+++ b/homeassistant/components/roborock/diagnostics.py
@@ -0,0 +1,38 @@
+"""Support for the Airzone diagnostics."""
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics.util import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_UNIQUE_ID
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+from .coordinator import RoborockDataUpdateCoordinator
+
+TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"]
+
+TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"]
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
+
+ return {
+ "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG),
+ "coordinators": {
+ f"**REDACTED-{i}**": {
+ "roborock_device_info": async_redact_data(
+ coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD
+ ),
+ "api": coordinator.api.diagnostic_data,
+ }
+ for i, coordinator in enumerate(coordinators.values())
+ },
+ }
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index 7cb686f18519..0cd437278cf7 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
- "requirements": ["python-roborock==0.8.3"]
+ "requirements": ["python-roborock==0.23.4"]
}
diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py
index 0377cebd425e..c1d32df2d6d3 100644
--- a/homeassistant/components/roborock/models.py
+++ b/homeassistant/components/roborock/models.py
@@ -1,8 +1,9 @@
"""Roborock Models."""
from dataclasses import dataclass
+from typing import Any
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
-from roborock.typing import DeviceProp
+from roborock.roborock_typing import DeviceProp
@dataclass
@@ -13,3 +14,12 @@ class RoborockHassDeviceInfo:
network_info: NetworkInfo
product: HomeDataProduct
props: DeviceProp
+
+ def as_dict(self) -> dict[str, dict[str, Any]]:
+ """Turn RoborockHassDeviceInfo into a dictionary."""
+ return {
+ "device": self.device.as_dict(),
+ "network_info": self.network_info.as_dict(),
+ "product": self.product.as_dict(),
+ "props": self.props.as_dict(),
+ }
diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py
index 646c49048543..2d76aac33d3b 100644
--- a/homeassistant/components/roborock/select.py
+++ b/homeassistant/components/roborock/select.py
@@ -2,31 +2,32 @@
from collections.abc import Callable
from dataclasses import dataclass
-from roborock.code_mappings import RoborockMopIntensityCode, RoborockMopModeCode
from roborock.containers import Status
-from roborock.exceptions import RoborockException
-from roborock.typing import RoborockCommand
+from roborock.roborock_typing import RoborockCommand
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity
-from .models import RoborockHassDeviceInfo
@dataclass
class RoborockSelectDescriptionMixin:
"""Define an entity description mixin for select entities."""
+ # The command that the select entity will send to the api.
api_command: RoborockCommand
+ # Gets the current value of the select entity.
value_fn: Callable[[Status], str]
- options_lambda: Callable[[str], list[int]]
+ # Gets all options of the select entity.
+ options_lambda: Callable[[Status], list[str]]
+ # Takes the value from the select entiy and converts it for the api.
+ parameter_lambda: Callable[[str, Status], list[int]]
@dataclass
@@ -40,22 +41,20 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
RoborockSelectDescription(
key="water_box_mode",
translation_key="mop_intensity",
- options=RoborockMopIntensityCode.values(),
api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE,
- value_fn=lambda data: data.water_box_mode,
- options_lambda=lambda data: [
- k for k, v in RoborockMopIntensityCode.items() if v == data
- ],
+ value_fn=lambda data: data.water_box_mode.name,
+ options_lambda=lambda data: data.water_box_mode.keys()
+ if data.water_box_mode
+ else None,
+ parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)],
),
RoborockSelectDescription(
key="mop_mode",
translation_key="mop_mode",
- options=RoborockMopModeCode.values(),
api_command=RoborockCommand.SET_MOP_MODE,
- value_fn=lambda data: data.mop_mode,
- options_lambda=lambda data: [
- k for k, v in RoborockMopModeCode.items() if v == data
- ],
+ value_fn=lambda data: data.mop_mode.name,
+ options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None,
+ parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)],
),
]
@@ -67,18 +66,19 @@ async def async_setup_entry(
) -> None:
"""Set up Roborock select platform."""
- coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][
+ coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][
config_entry.entry_id
]
async_add_entities(
RoborockSelectEntity(
f"{description.key}_{slugify(device_id)}",
- device_info,
coordinator,
description,
)
- for device_id, device_info in coordinator.devices_info.items()
+ for device_id, coordinator in coordinators.items()
for description in SELECT_DESCRIPTIONS
+ if description.options_lambda(coordinator.roborock_device_info.props.status)
+ is not None
)
@@ -90,25 +90,20 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity):
def __init__(
self,
unique_id: str,
- device_info: RoborockHassDeviceInfo,
coordinator: RoborockDataUpdateCoordinator,
entity_description: RoborockSelectDescription,
) -> None:
"""Create a select entity."""
self.entity_description = entity_description
- super().__init__(unique_id, device_info, coordinator)
+ super().__init__(unique_id, coordinator)
+ self._attr_options = self.entity_description.options_lambda(self._device_status)
async def async_select_option(self, option: str) -> None:
- """Set the mop intensity."""
- try:
- await self.send(
- self.entity_description.api_command,
- self.entity_description.options_lambda(option),
- )
- except RoborockException as err:
- raise HomeAssistantError(
- f"Error while setting {self.entity_description.key} to {option}"
- ) from err
+ """Set the option."""
+ await self.send(
+ self.entity_description.api_command,
+ self.entity_description.parameter_lambda(option, self._device_status),
+ )
@property
def current_option(self) -> str | None:
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
new file mode 100644
index 000000000000..ec973addae39
--- /dev/null
+++ b/homeassistant/components/roborock/sensor.py
@@ -0,0 +1,145 @@
+"""Support for Roborock sensors."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from roborock.containers import RoborockStateCode
+from roborock.roborock_typing import DeviceProp
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory, UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.util import slugify
+
+from .const import DOMAIN
+from .coordinator import RoborockDataUpdateCoordinator
+from .device import RoborockCoordinatedEntity
+
+
+@dataclass
+class RoborockSensorDescriptionMixin:
+ """A class that describes sensor entities."""
+
+ value_fn: Callable[[DeviceProp], int]
+
+
+@dataclass
+class RoborockSensorDescription(
+ SensorEntityDescription, RoborockSensorDescriptionMixin
+):
+ """A class that describes Roborock sensors."""
+
+
+SENSOR_DESCRIPTIONS = [
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="main_brush_time_left",
+ icon="mdi:brush",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="main_brush_time_left",
+ value_fn=lambda data: data.consumable.main_brush_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="side_brush_time_left",
+ icon="mdi:brush",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="side_brush_time_left",
+ value_fn=lambda data: data.consumable.side_brush_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="filter_time_left",
+ icon="mdi:air-filter",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="filter_time_left",
+ value_fn=lambda data: data.consumable.filter_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="sensor_time_left",
+ icon="mdi:eye-outline",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="sensor_time_left",
+ value_fn=lambda data: data.consumable.sensor_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="cleaning_time",
+ translation_key="cleaning_time",
+ device_class=SensorDeviceClass.DURATION,
+ value_fn=lambda data: data.status.clean_time,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ key="total_cleaning_time",
+ translation_key="total_cleaning_time",
+ icon="mdi:history",
+ device_class=SensorDeviceClass.DURATION,
+ value_fn=lambda data: data.clean_summary.clean_time,
+ ),
+ RoborockSensorDescription(
+ key="status",
+ icon="mdi:information-outline",
+ device_class=SensorDeviceClass.ENUM,
+ translation_key="status",
+ value_fn=lambda data: data.status.state.name,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=RoborockStateCode.keys(),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Roborock vacuum sensors."""
+ coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
+ async_add_entities(
+ RoborockSensorEntity(
+ f"{description.key}_{slugify(device_id)}",
+ coordinator,
+ description,
+ )
+ for device_id, coordinator in coordinators.items()
+ for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity):
+ """Representation of a Roborock sensor."""
+
+ entity_description: RoborockSensorDescription
+
+ def __init__(
+ self,
+ unique_id: str,
+ coordinator: RoborockDataUpdateCoordinator,
+ description: RoborockSensorDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(unique_id, coordinator)
+ self.entity_description = description
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the value reported by the sensor."""
+ return self.entity_description.value_fn(
+ self.coordinator.roborock_device_info.props
+ )
diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json
index 6bd19787d203..00ebd3833a80 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -27,6 +27,54 @@
}
},
"entity": {
+ "sensor": {
+ "cleaning_time": {
+ "name": "Cleaning time"
+ },
+ "main_brush_time_left": {
+ "name": "Main brush time left"
+ },
+ "side_brush_time_left": {
+ "name": "Side brush time left"
+ },
+ "filter_time_left": {
+ "name": "Filter time left"
+ },
+ "sensor_time_left": {
+ "name": "Sensor time left"
+ },
+ "status": {
+ "name": "Status",
+ "state": {
+ "starting": "Starting",
+ "charger_disconnected": "Charger disconnected",
+ "idle": "Idle",
+ "remote_control_active": "Remote control active",
+ "cleaning": "Cleaning",
+ "returning_home": "Returning home",
+ "manual_mode": "Manual mode",
+ "charging": "Charging",
+ "charging_problem": "Charging problem",
+ "paused": "Paused",
+ "spot_cleaning": "Spot cleaning",
+ "error": "Error",
+ "shutting_down": "Shutting down",
+ "updating": "Updating",
+ "docking": "Docking",
+ "going_to_target": "Going to target",
+ "zoned_cleaning": "Zoned cleaning",
+ "segment_cleaning": "Segment cleaning",
+ "emptying_the_bin": "Emptying the bin",
+ "washing_the_mop": "Washing the mop",
+ "going_to_wash_the_mop": "Going to wash the mop",
+ "charging_complete": "Charging complete",
+ "device_offline": "Device offline"
+ }
+ },
+ "total_cleaning_time": {
+ "name": "Total cleaning time"
+ }
+ },
"select": {
"mop_mode": {
"name": "Mop mode",
@@ -34,19 +82,53 @@
"standard": "Standard",
"deep": "Deep",
"deep_plus": "Deep+",
- "custom": "Custom"
+ "custom": "Custom",
+ "fast": "Fast"
}
},
"mop_intensity": {
"name": "Mop intensity",
"state": {
"off": "Off",
+ "low": "Low",
"mild": "Mild",
+ "medium": "Medium",
"moderate": "Moderate",
+ "high": "High",
"intense": "Intense",
"custom": "Custom"
}
}
+ },
+ "switch": {
+ "child_lock": {
+ "name": "Child lock"
+ },
+ "status_indicator": {
+ "name": "Status indicator light"
+ }
+ },
+ "vacuum": {
+ "roborock": {
+ "state_attributes": {
+ "fan_speed": {
+ "state": {
+ "auto": "Auto",
+ "balanced": "Balanced",
+ "custom": "Custom",
+ "gentle": "Gentle",
+ "off": "Off",
+ "max": "Max",
+ "max_plus": "Max plus",
+ "medium": "Medium",
+ "quiet": "Quiet",
+ "silent": "Silent",
+ "standard": "Standard",
+ "turbo": "Turbo"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py
new file mode 100644
index 000000000000..d8ff50430cbc
--- /dev/null
+++ b/homeassistant/components/roborock/switch.py
@@ -0,0 +1,153 @@
+"""Support for Roborock switch."""
+import asyncio
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from roborock.exceptions import RoborockException
+from roborock.roborock_typing import RoborockCommand
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import slugify
+
+from .const import DOMAIN
+from .coordinator import RoborockDataUpdateCoordinator
+from .device import RoborockEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class RoborockSwitchDescriptionMixin:
+ """Define an entity description mixin for switch entities."""
+
+ # Gets the status of the switch
+ get_value: Callable[[RoborockEntity], Coroutine[Any, Any, dict]]
+ # Evaluate the result of get_value to determine a bool
+ evaluate_value: Callable[[dict], bool]
+ # Sets the status of the switch
+ set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]]
+ # Check support of this feature
+ check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]]
+
+
+@dataclass
+class RoborockSwitchDescription(
+ SwitchEntityDescription, RoborockSwitchDescriptionMixin
+):
+ """Class to describe an Roborock switch entity."""
+
+
+SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
+ RoborockSwitchDescription(
+ set_command=lambda entity, value: entity.send(
+ RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0}
+ ),
+ get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS),
+ check_support=lambda data: data.api.send_command(
+ RoborockCommand.GET_CHILD_LOCK_STATUS
+ ),
+ evaluate_value=lambda data: data["lock_status"] == 1,
+ key="child_lock",
+ translation_key="child_lock",
+ icon="mdi:account-lock",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ RoborockSwitchDescription(
+ set_command=lambda entity, value: entity.send(
+ RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0}
+ ),
+ get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS),
+ check_support=lambda data: data.api.send_command(
+ RoborockCommand.GET_FLOW_LED_STATUS
+ ),
+ evaluate_value=lambda data: data["status"] == 1,
+ key="status_indicator",
+ translation_key="status_indicator",
+ icon="mdi:alarm-light-outline",
+ entity_category=EntityCategory.CONFIG,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Roborock switch platform."""
+
+ coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][
+ config_entry.entry_id
+ ]
+ possible_entities: list[
+ tuple[str, RoborockDataUpdateCoordinator, RoborockSwitchDescription]
+ ] = [
+ (device_id, coordinator, description)
+ for device_id, coordinator in coordinators.items()
+ for description in SWITCH_DESCRIPTIONS
+ ]
+ # We need to check if this function is supported by the device.
+ results = await asyncio.gather(
+ *(
+ description.check_support(coordinator)
+ for _, coordinator, description in possible_entities
+ ),
+ return_exceptions=True,
+ )
+ valid_entities: list[RoborockSwitchEntity] = []
+ for posible_entity, result in zip(possible_entities, results):
+ if isinstance(result, Exception):
+ if not isinstance(result, RoborockException):
+ raise result
+ _LOGGER.debug("Not adding entity because of %s", result)
+ else:
+ valid_entities.append(
+ RoborockSwitchEntity(
+ f"{posible_entity[2].key}_{slugify(posible_entity[0])}",
+ posible_entity[1],
+ posible_entity[2],
+ result,
+ )
+ )
+ async_add_entities(
+ valid_entities,
+ True,
+ )
+
+
+class RoborockSwitchEntity(RoborockEntity, SwitchEntity):
+ """A class to let you turn functionality on Roborock devices on and off."""
+
+ entity_description: RoborockSwitchDescription
+
+ def __init__(
+ self,
+ unique_id: str,
+ coordinator: RoborockDataUpdateCoordinator,
+ entity_description: RoborockSwitchDescription,
+ initial_value: bool,
+ ) -> None:
+ """Create a switch entity."""
+ self.entity_description = entity_description
+ super().__init__(unique_id, coordinator.device_info, coordinator.api)
+ self._attr_is_on = initial_value
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ await self.entity_description.set_command(self, False)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ await self.entity_description.set_command(self, True)
+
+ async def async_update(self) -> None:
+ """Update switch."""
+ self._attr_is_on = self.entity_description.evaluate_value(
+ await self.entity_description.get_value(self)
+ )
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index 4306afb25e41..9e486fa54b17 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -1,8 +1,8 @@
"""Support for Roborock vacuum class."""
from typing import Any
-from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode
-from roborock.typing import RoborockCommand
+from roborock.code_mappings import RoborockStateCode
+from roborock.roborock_typing import RoborockCommand
from homeassistant.components.vacuum import (
STATE_CLEANING,
@@ -22,51 +22,46 @@ from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity
-from .models import RoborockHassDeviceInfo
STATE_CODE_TO_STATE = {
- RoborockStateCode["1"]: STATE_IDLE, # "Starting"
- RoborockStateCode["2"]: STATE_IDLE, # "Charger disconnected"
- RoborockStateCode["3"]: STATE_IDLE, # "Idle"
- RoborockStateCode["4"]: STATE_CLEANING, # "Remote control active"
- RoborockStateCode["5"]: STATE_CLEANING, # "Cleaning"
- RoborockStateCode["6"]: STATE_RETURNING, # "Returning home"
- RoborockStateCode["7"]: STATE_CLEANING, # "Manual mode"
- RoborockStateCode["8"]: STATE_DOCKED, # "Charging"
- RoborockStateCode["9"]: STATE_ERROR, # "Charging problem"
- RoborockStateCode["10"]: STATE_PAUSED, # "Paused"
- RoborockStateCode["11"]: STATE_CLEANING, # "Spot cleaning"
- RoborockStateCode["12"]: STATE_ERROR, # "Error"
- RoborockStateCode["13"]: STATE_IDLE, # "Shutting down"
- RoborockStateCode["14"]: STATE_DOCKED, # "Updating"
- RoborockStateCode["15"]: STATE_RETURNING, # "Docking"
- RoborockStateCode["16"]: STATE_CLEANING, # "Going to target"
- RoborockStateCode["17"]: STATE_CLEANING, # "Zoned cleaning"
- RoborockStateCode["18"]: STATE_CLEANING, # "Segment cleaning"
- RoborockStateCode["22"]: STATE_DOCKED, # "Emptying the bin" on s7+
- RoborockStateCode["23"]: STATE_DOCKED, # "Washing the mop" on s7maxV
- RoborockStateCode["26"]: STATE_RETURNING, # "Going to wash the mop" on s7maxV
- RoborockStateCode["100"]: STATE_DOCKED, # "Charging complete"
- RoborockStateCode["101"]: STATE_ERROR, # "Device offline"
+ RoborockStateCode.starting: STATE_IDLE, # "Starting"
+ RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected"
+ RoborockStateCode.idle: STATE_IDLE, # "Idle"
+ RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active"
+ RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning"
+ RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home"
+ RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode"
+ RoborockStateCode.charging: STATE_DOCKED, # "Charging"
+ RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem"
+ RoborockStateCode.paused: STATE_PAUSED, # "Paused"
+ RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning"
+ RoborockStateCode.error: STATE_ERROR, # "Error"
+ RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down"
+ RoborockStateCode.updating: STATE_DOCKED, # "Updating"
+ RoborockStateCode.docking: STATE_RETURNING, # "Docking"
+ RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target"
+ RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning"
+ RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning"
+ RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+
+ RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV
+ RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV
+ RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete"
+ RoborockStateCode.device_offline: STATE_ERROR, # "Device offline"
}
-ATTR_STATUS = "status"
-ATTR_ERROR = "error"
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Roborock sensor."""
- coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][
+ coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][
config_entry.entry_id
]
async_add_entities(
- RoborockVacuum(slugify(device_id), device_info, coordinator)
- for device_id, device_info in coordinator.devices_info.items()
+ RoborockVacuum(slugify(device_id), coordinator)
+ for device_id, coordinator in coordinators.items()
)
@@ -87,28 +82,23 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
| VacuumEntityFeature.STATE
| VacuumEntityFeature.START
)
- _attr_fan_speed_list = RoborockFanPowerCode.values()
+ _attr_translation_key = DOMAIN
def __init__(
self,
unique_id: str,
- device: RoborockHassDeviceInfo,
coordinator: RoborockDataUpdateCoordinator,
) -> None:
"""Initialize a vacuum."""
StateVacuumEntity.__init__(self)
- RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator)
+ RoborockCoordinatedEntity.__init__(self, unique_id, coordinator)
+ self._attr_fan_speed_list = self._device_status.fan_power.keys()
@property
def state(self) -> str | None:
"""Return the status of the vacuum cleaner."""
return STATE_CODE_TO_STATE.get(self._device_status.state)
- @property
- def status(self) -> str | None:
- """Return the status of the vacuum cleaner."""
- return self._device_status.status
-
@property
def battery_level(self) -> int | None:
"""Return the battery level of the vacuum cleaner."""
@@ -117,12 +107,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
@property
def fan_speed(self) -> str | None:
"""Return the fan speed of the vacuum cleaner."""
- return self._device_status.fan_power
+ return self._device_status.fan_power.name
@property
- def error(self) -> str | None:
- """Get the error str if an error code exists."""
- return self._device_status.error
+ def status(self) -> str | None:
+ """Return the status of the vacuum cleaner."""
+ return self._device_status.state.name
async def async_start(self) -> None:
"""Start the vacuum."""
@@ -152,11 +142,11 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
"""Set vacuum fan speed."""
await self.send(
RoborockCommand.SET_CUSTOM_MODE,
- [k for k, v in RoborockFanPowerCode.items() if v == fan_speed],
+ [self._device_status.fan_power.as_dict().get(fan_speed)],
)
await self.coordinator.async_request_refresh()
- async def async_start_pause(self):
+ async def async_start_pause(self) -> None:
"""Start, pause or resume the cleaning task."""
if self.state == STATE_CLEANING:
await self.async_pause()
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index 944304aa6453..f9b81dc8ddd1 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -11,7 +11,7 @@
"iot_class": "local_polling",
"loggers": ["rokuecp"],
"quality_scale": "silver",
- "requirements": ["rokuecp==0.17.1"],
+ "requirements": ["rokuecp==0.18.0"],
"ssdp": [
{
"st": "roku:ecp",
diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py
index 0a1c51ca38ce..e4fb45865a2a 100644
--- a/homeassistant/components/roomba/config_flow.py
+++ b/homeassistant/components/roomba/config_flow.py
@@ -10,7 +10,7 @@ from roombapy.getpassword import RoombaPassword
import voluptuous as vol
from homeassistant import config_entries, core
-from homeassistant.components import dhcp
+from homeassistant.components import dhcp, zeroconf
from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
@@ -85,17 +85,31 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
+ async def async_step_zeroconf(
+ self, discovery_info: zeroconf.ZeroconfServiceInfo
+ ) -> FlowResult:
+ """Handle zeroconf discovery."""
+ return await self._async_step_discovery(
+ discovery_info.host, discovery_info.hostname.lower().rstrip(".local.")
+ )
+
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
"""Handle dhcp discovery."""
- self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
+ return await self._async_step_discovery(
+ discovery_info.ip, discovery_info.hostname
+ )
- if not discovery_info.hostname.startswith(("irobot-", "roomba-")):
+ async def _async_step_discovery(self, ip_address: str, hostname: str) -> FlowResult:
+ """Handle any discovery."""
+ self._async_abort_entries_match({CONF_HOST: ip_address})
+
+ if not hostname.startswith(("irobot-", "roomba-")):
return self.async_abort(reason="not_irobot_device")
- self.host = discovery_info.ip
- self.blid = _async_blid_from_hostname(discovery_info.hostname)
+ self.host = ip_address
+ self.blid = _async_blid_from_hostname(hostname)
await self.async_set_unique_id(self.blid)
- self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
+ self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
# Because the hostname is so long some sources may
# truncate the hostname since it will be longer than
@@ -103,7 +117,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# going for a longer hostname we abort so the user
# does not see two flows if discovery fails.
for progress in self._async_in_progress():
- flow_unique_id = progress["context"]["unique_id"]
+ flow_unique_id: str = progress["context"]["unique_id"]
if flow_unique_id.startswith(self.blid):
return self.async_abort(reason="short_blid")
if self.blid.startswith(flow_unique_id):
diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json
index 7b437a4f8c48..9e18465922a4 100644
--- a/homeassistant/components/roomba/manifest.json
+++ b/homeassistant/components/roomba/manifest.json
@@ -24,5 +24,15 @@
"documentation": "https://www.home-assistant.io/integrations/roomba",
"iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"],
- "requirements": ["roombapy==1.6.8"]
+ "requirements": ["roombapy==1.6.8"],
+ "zeroconf": [
+ {
+ "type": "_amzn-alexa._tcp.local.",
+ "name": "irobot-*"
+ },
+ {
+ "type": "_amzn-alexa._tcp.local.",
+ "name": "roomba-*"
+ }
+ ]
}
diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py
index 0b8b6e92eb34..2e345905d502 100644
--- a/homeassistant/components/sabnzbd/__init__.py
+++ b/homeassistant/components/sabnzbd/__init__.py
@@ -237,7 +237,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SabnzbdApiException as err:
_LOGGER.error(err)
- async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL)
+ entry.async_on_unload(
+ async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL)
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py
index 2f010fe79c9d..3ed2d4476af9 100644
--- a/homeassistant/components/safe_mode/__init__.py
+++ b/homeassistant/components/safe_mode/__init__.py
@@ -1,10 +1,13 @@
"""The Safe Mode integration."""
from homeassistant.components import persistent_notification
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "safe_mode"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Safe Mode component."""
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
index 55d0fbdfbdb9..b7d400ce8316 100644
--- a/homeassistant/components/samsungtv/__init__.py
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -3,14 +3,11 @@ from __future__ import annotations
from collections.abc import Coroutine, Mapping
from functools import partial
-import socket
from typing import Any
from urllib.parse import urlparse
import getmac
-import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -18,7 +15,6 @@ from homeassistant.const import (
CONF_MAC,
CONF_METHOD,
CONF_MODEL,
- CONF_NAME,
CONF_PORT,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STOP,
@@ -30,10 +26,8 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
- issue_registry as ir,
)
from homeassistant.helpers.debounce import Debouncer
-from homeassistant.helpers.typing import ConfigType
from .bridge import (
SamsungTVBridge,
@@ -42,11 +36,9 @@ from .bridge import (
model_requires_encryption,
)
from .const import (
- CONF_ON_ACTION,
CONF_SESSION_ID,
CONF_SSDP_MAIN_TV_AGENT_LOCATION,
CONF_SSDP_RENDERING_CONTROL_LOCATION,
- DEFAULT_NAME,
DOMAIN,
ENTRY_RELOAD_COOLDOWN,
LEGACY_PORT,
@@ -57,73 +49,9 @@ from .const import (
UPNP_SVC_RENDERING_CONTROL,
)
+PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
-def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]:
- """Validate that all configs have a unique host."""
- vol.Schema(vol.Unique("duplicate host entries found"))(
- [entry[CONF_HOST] for entry in value]
- )
- return value
-
-
-PLATFORMS = [Platform.MEDIA_PLAYER]
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.All(
- cv.ensure_list,
- [
- cv.deprecated(CONF_PORT),
- vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PORT): cv.port,
- vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
- }
- ),
- ],
- ensure_unique_hosts,
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Samsung TV integration."""
- hass.data[DOMAIN] = {}
- if DOMAIN not in config:
- return True
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml",
- breaks_in_ha_version="2023.6.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action"
- },
- learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action",
- )
- for entry_config in config[DOMAIN]:
- ip_address = await hass.async_add_executor_job(
- socket.gethostbyname, entry_config[CONF_HOST]
- )
- hass.data[DOMAIN][ip_address] = {
- CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION)
- }
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data=entry_config,
- )
- )
- return True
+CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@callback
@@ -195,6 +123,7 @@ async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Samsung TV platform."""
+ hass.data.setdefault(DOMAIN, {})
# Initialize bridge
if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET:
diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py
index a2558367995a..ba09cf9fe3b1 100644
--- a/homeassistant/components/samsungtv/bridge.py
+++ b/homeassistant/components/samsungtv/bridge.py
@@ -6,6 +6,7 @@ import asyncio
from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from collections.abc import Callable, Iterable, Mapping
import contextlib
+from datetime import datetime, timedelta
from typing import Any, Generic, TypeVar, cast
from samsungctl import Remote
@@ -43,8 +44,10 @@ from homeassistant.const import (
CONF_TOKEN,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
+from homeassistant.helpers import entity_component
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.util import dt as dt_util
from .const import (
CONF_DESCRIPTION,
@@ -67,6 +70,13 @@ from .const import (
WEBSOCKET_PORTS,
)
+# Since the TV will take a few seconds to go to sleep
+# and actually be seen as off, we need to wait just a bit
+# more than the next scan interval
+SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta(
+ seconds=5
+)
+
KEY_PRESS_TIMEOUT = 1.2
ENCRYPTED_MODEL_USES_POWER_OFF = {"H6400", "H6410"}
@@ -161,6 +171,10 @@ class SamsungTVBridge(ABC):
self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None
self._app_list_callback: Callable[[dict[str, str]], None] | None = None
+ # Mark the end of a shutdown command (need to wait 15 seconds before
+ # sending the next command to avoid turning the TV back ON).
+ self._end_of_power_off: datetime | None = None
+
def register_reauth_callback(self, func: CALLBACK_TYPE) -> None:
"""Register a callback function."""
self._reauth_callback = func
@@ -203,8 +217,17 @@ class SamsungTVBridge(ABC):
async def async_send_keys(self, keys: list[str]) -> None:
"""Send a list of keys to the tv."""
+ @property
+ def power_off_in_progress(self) -> bool:
+ """Return if power off has been recently requested."""
+ return (
+ self._end_of_power_off is not None
+ and self._end_of_power_off > dt_util.utcnow()
+ )
+
async def async_power_off(self) -> None:
"""Send power off command to remote and close."""
+ self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME
await self._async_send_power_off()
# Force closing of remote session to provide instant UI feedback
await self.async_close_remote()
diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py
index bc8cc895849c..f98e3667b592 100644
--- a/homeassistant/components/samsungtv/config_flow.py
+++ b/homeassistant/components/samsungtv/config_flow.py
@@ -35,12 +35,9 @@ from .const import (
CONF_SSDP_RENDERING_CONTROL_LOCATION,
DEFAULT_MANUFACTURER,
DOMAIN,
- ENCRYPTED_WEBSOCKET_PORT,
- LEGACY_PORT,
LOGGER,
METHOD_ENCRYPTED_WEBSOCKET,
METHOD_LEGACY,
- METHOD_WEBSOCKET,
RESULT_AUTH_MISSING,
RESULT_CANNOT_CONNECT,
RESULT_INVALID_PIN,
@@ -50,7 +47,6 @@ from .const import (
SUCCESSFUL_RESULTS,
UPNP_SVC_MAIN_TV_AGENT,
UPNP_SVC_RENDERING_CONTROL,
- WEBSOCKET_PORTS,
)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str})
@@ -231,25 +227,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._mac = mac
return True
- async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
- """Handle configuration by yaml file."""
- # We need to import even if we cannot validate
- # since the TV may be off at startup
- await self._async_set_name_host_from_input(user_input)
- self._async_abort_entries_match({CONF_HOST: self._host})
- port = user_input.get(CONF_PORT)
- if port in WEBSOCKET_PORTS:
- user_input[CONF_METHOD] = METHOD_WEBSOCKET
- elif port == ENCRYPTED_WEBSOCKET_PORT:
- user_input[CONF_METHOD] = METHOD_ENCRYPTED_WEBSOCKET
- elif port == LEGACY_PORT:
- user_input[CONF_METHOD] = METHOD_LEGACY
- user_input[CONF_MANUFACTURER] = DEFAULT_MANUFACTURER
- return self.async_create_entry(
- title=self._title,
- data=user_input,
- )
-
async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None:
try:
self._host = await self.hass.async_add_executor_job(
diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py
index 2585d742be00..6699d26243bb 100644
--- a/homeassistant/components/samsungtv/const.py
+++ b/homeassistant/components/samsungtv/const.py
@@ -6,7 +6,6 @@ DOMAIN = "samsungtv"
ATTR_PROPERTIES = "properties"
-DEFAULT_NAME = "Samsung TV"
DEFAULT_MANUFACTURER = "Samsung"
VALUE_CONF_NAME = "HomeAssistant"
@@ -16,7 +15,6 @@ CONF_DESCRIPTION = "description"
CONF_MANUFACTURER = "manufacturer"
CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location"
CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location"
-CONF_ON_ACTION = "turn_on_action"
CONF_SESSION_ID = "session_id"
RESULT_AUTH_MISSING = "auth_missing"
diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py
new file mode 100644
index 000000000000..418feecbf940
--- /dev/null
+++ b/homeassistant/components/samsungtv/entity.py
@@ -0,0 +1,32 @@
+"""Base SamsungTV Entity."""
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity import DeviceInfo, Entity
+
+from .bridge import SamsungTVBridge
+from .const import CONF_MANUFACTURER, DOMAIN
+
+
+class SamsungTVEntity(Entity):
+ """Defines a base SamsungTV entity."""
+
+ def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None:
+ """Initialize the SamsungTV entity."""
+ self._bridge = bridge
+ self._mac = config_entry.data.get(CONF_MAC)
+ self._attr_name = config_entry.data.get(CONF_NAME)
+ self._attr_unique_id = config_entry.unique_id
+ self._attr_device_info = DeviceInfo(
+ name=self.name,
+ manufacturer=config_entry.data.get(CONF_MANUFACTURER),
+ model=config_entry.data.get(CONF_MODEL),
+ )
+ if self.unique_id:
+ self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)}
+ if self._mac:
+ self._attr_device_info["connections"] = {
+ (dr.CONNECTION_NETWORK_MAC, self._mac)
+ }
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index 6e3bbe6b1a83..9d00282d8daa 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -37,7 +37,7 @@
"requirements": [
"getmac==0.8.2",
"samsungctl[websocket]==0.7.1",
- "samsungtvws[async,encrypted]==2.5.0",
+ "samsungtvws[async,encrypted]==2.6.0",
"wakeonlan==2.1.0",
"async-upnp-client==0.33.2"
],
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
index 302d9c4915df..2f82c979b940 100644
--- a/homeassistant/components/samsungtv/media_player.py
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine, Sequence
-from datetime import datetime, timedelta
from typing import Any
import async_timeout
@@ -31,29 +30,16 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import (
- config_validation as cv,
- device_registry as dr,
- entity_component,
-)
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.trigger import PluggableAction
-from homeassistant.util import dt as dt_util
from .bridge import SamsungTVBridge, SamsungTVWSBridge
-from .const import (
- CONF_MANUFACTURER,
- CONF_ON_ACTION,
- CONF_SSDP_RENDERING_CONTROL_LOCATION,
- DEFAULT_NAME,
- DOMAIN,
- LOGGER,
-)
+from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER
+from .entity import SamsungTVEntity
from .triggers.turn_on import async_get_turn_on_trigger
SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}
@@ -70,12 +56,6 @@ SUPPORT_SAMSUNGTV = (
| MediaPlayerEntityFeature.PLAY_MEDIA
)
-# Since the TV will take a few seconds to go to sleep
-# and actually be seen as off, we need to wait just a bit
-# more than the next scan interval
-SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta(
- seconds=5
-)
# Max delay waiting for app_list to return, as some TVs simply ignore the request
APP_LIST_DELAY = 3
@@ -86,19 +66,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Samsung TV from a config entry."""
bridge = hass.data[DOMAIN][entry.entry_id]
-
- host = entry.data[CONF_HOST]
- on_script = None
- data = hass.data[DOMAIN]
- if turn_on_action := data.get(host, {}).get(CONF_ON_ACTION):
- on_script = Script(
- hass, turn_on_action, entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN
- )
-
- async_add_entities([SamsungTVDevice(bridge, entry, on_script)], True)
+ async_add_entities([SamsungTVDevice(bridge, entry)], True)
-class SamsungTVDevice(MediaPlayerEntity):
+class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity):
"""Representation of a Samsung TV."""
_attr_source_list: list[str]
@@ -107,22 +78,18 @@ class SamsungTVDevice(MediaPlayerEntity):
self,
bridge: SamsungTVBridge,
config_entry: ConfigEntry,
- on_script: Script | None,
) -> None:
"""Initialize the Samsung device."""
+ super().__init__(bridge=bridge, config_entry=config_entry)
self._config_entry = config_entry
self._host: str | None = config_entry.data[CONF_HOST]
- self._mac: str | None = config_entry.data.get(CONF_MAC)
self._ssdp_rendering_control_location: str | None = config_entry.data.get(
CONF_SSDP_RENDERING_CONTROL_LOCATION
)
self._turn_on = PluggableAction(self.async_write_ha_state)
- self._on_script = on_script
# Assume that the TV is in Play mode
self._playing: bool = True
- self._attr_name: str | None = config_entry.data.get(CONF_NAME)
- self._attr_unique_id = config_entry.unique_id
self._attr_is_volume_muted: bool = False
self._attr_device_class = MediaPlayerDeviceClass.TV
self._attr_source_list = list(SOURCES)
@@ -130,29 +97,13 @@ class SamsungTVDevice(MediaPlayerEntity):
self._app_list_event: asyncio.Event = asyncio.Event()
self._attr_supported_features = SUPPORT_SAMSUNGTV
- if self._on_script or self._mac:
- # (deprecated) add turn-on if on_script YAML or mac is available
+ if self._mac:
+ # (deprecated) add turn-on if mac is available
# Triggers have not yet been registered so this is adjusted in the property
self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON
if self._ssdp_rendering_control_location:
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET
- self._attr_device_info = DeviceInfo(
- name=self.name,
- manufacturer=config_entry.data.get(CONF_MANUFACTURER),
- model=config_entry.data.get(CONF_MODEL),
- )
- if self.unique_id:
- self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)}
- if self._mac:
- self._attr_device_info["connections"] = {
- (dr.CONNECTION_NETWORK_MAC, self._mac)
- }
-
- # Mark the end of a shutdown command (need to wait 15 seconds before
- # sending the next command to avoid turning the TV back ON).
- self._end_of_power_off: datetime | None = None
- self._bridge = bridge
self._auth_failed = False
self._bridge.register_reauth_callback(self.access_denied)
self._bridge.register_app_list_callback(self._app_list_callback)
@@ -204,7 +155,7 @@ class SamsungTVDevice(MediaPlayerEntity):
if self._auth_failed or self.hass.is_stopping:
return
old_state = self._attr_state
- if self._power_off_in_progress():
+ if self._bridge.power_off_in_progress:
self._attr_state = MediaPlayerState.OFF
else:
self._attr_state = (
@@ -347,7 +298,7 @@ class SamsungTVDevice(MediaPlayerEntity):
async def _async_launch_app(self, app_id: str) -> None:
"""Send launch_app to the tv."""
- if self._power_off_in_progress():
+ if self._bridge.power_off_in_progress:
LOGGER.info("TV is powering off, not sending launch_app command")
return
assert isinstance(self._bridge, SamsungTVWSBridge)
@@ -356,17 +307,11 @@ class SamsungTVDevice(MediaPlayerEntity):
async def _async_send_keys(self, keys: list[str]) -> None:
"""Send a key to the tv and handles exceptions."""
assert keys
- if self._power_off_in_progress() and keys[0] != "KEY_POWEROFF":
+ if self._bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF":
LOGGER.info("TV is powering off, not sending keys: %s", keys)
return
await self._bridge.async_send_keys(keys)
- def _power_off_in_progress(self) -> bool:
- return (
- self._end_of_power_off is not None
- and self._end_of_power_off > dt_util.utcnow()
- )
-
@property
def available(self) -> bool:
"""Return the availability of the device."""
@@ -375,9 +320,8 @@ class SamsungTVDevice(MediaPlayerEntity):
return (
self.state == MediaPlayerState.ON
or bool(self._turn_on)
- or self._on_script is not None
or self._mac is not None
- or self._power_off_in_progress()
+ or self._bridge.power_off_in_progress
)
async def async_added_to_hass(self) -> None:
@@ -393,7 +337,6 @@ class SamsungTVDevice(MediaPlayerEntity):
async def async_turn_off(self) -> None:
"""Turn off media player."""
- self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME
await self._bridge.async_power_off()
async def async_set_volume_level(self, volume: float) -> None:
@@ -477,9 +420,6 @@ class SamsungTVDevice(MediaPlayerEntity):
"""Turn the media player on."""
if self._turn_on:
await self._turn_on.async_run(self.hass, self._context)
- elif self._on_script:
- # YAML on_script is deprecated - replaced by turn_on trigger
- await self._on_script.async_run(context=self._context)
elif self._mac:
await self.hass.async_add_executor_job(self._wake_on_lan)
diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py
new file mode 100644
index 000000000000..22857d966596
--- /dev/null
+++ b/homeassistant/components/samsungtv/remote.py
@@ -0,0 +1,47 @@
+"""Support for the SamsungTV remote."""
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+
+from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, LOGGER
+from .entity import SamsungTVEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the Samsung TV from a config entry."""
+ bridge = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)])
+
+
+class SamsungTVRemote(SamsungTVEntity, RemoteEntity):
+ """Device that sends commands to a SamsungTV."""
+
+ _attr_should_poll = False
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self._bridge.async_power_off()
+
+ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
+ """Send a command to a device.
+
+ Supported keys vary between models.
+ See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md
+ """
+ if self._bridge.power_off_in_progress:
+ LOGGER.info("TV is powering off, not sending keys: %s", command)
+ return
+
+ num_repeats = kwargs[ATTR_NUM_REPEATS]
+ command_list = list(command)
+
+ for _ in range(num_repeats):
+ await self._bridge.async_send_keys(command_list)
diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json
index cfa04244e829..f1f237fa4fb0 100644
--- a/homeassistant/components/samsungtv/strings.json
+++ b/homeassistant/components/samsungtv/strings.json
@@ -44,11 +44,5 @@
"trigger_type": {
"samsungtv.turn_on": "Device is requested to turn on"
}
- },
- "issues": {
- "deprecated_yaml": {
- "title": "The SamsungTV YAML configuration is being removed",
- "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))."
- }
}
}
diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py
index 9c4137c1beac..659131e902bb 100644
--- a/homeassistant/components/script/__init__.py
+++ b/homeassistant/components/script/__init__.py
@@ -160,6 +160,20 @@ def scripts_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str
]
+@callback
+def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None:
+ """Return the blueprint the script is based on or None."""
+ if DOMAIN not in hass.data:
+ return None
+
+ component: EntityComponent[ScriptEntity] = hass.data[DOMAIN]
+
+ if (script_entity := component.get_entity(entity_id)) is None:
+ return None
+
+ return script_entity.referenced_blueprint
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Load the scripts from the configuration."""
hass.data[DOMAIN] = component = EntityComponent[ScriptEntity](LOGGER, DOMAIN, hass)
diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py
index b574081d5d42..69796800e615 100644
--- a/homeassistant/components/search/__init__.py
+++ b/homeassistant/components/search/__init__.py
@@ -10,13 +10,19 @@ import voluptuous as vol
from homeassistant.components import automation, group, person, script, websocket_api
from homeassistant.components.homeassistant import scene
from homeassistant.core import HomeAssistant, callback, split_entity_id
-from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.entity import entity_sources as get_entity_sources
from homeassistant.helpers.typing import ConfigType
DOMAIN = "search"
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Search component."""
@@ -31,7 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
(
"area",
"automation",
- "blueprint",
+ "automation_blueprint",
"config_entry",
"device",
"entity",
@@ -39,6 +45,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"person",
"scene",
"script",
+ "script_blueprint",
)
),
vol.Required("item_id"): str,
@@ -75,10 +82,12 @@ class Searcher:
DONT_RESOLVE = {
"area",
"automation",
+ "automation_blueprint",
"config_entry",
"group",
"scene",
"script",
+ "script_blueprint",
}
# These types exist as an entity and so need cleanup in results
EXIST_AS_ENTITY = {"automation", "group", "person", "scene", "script"}
@@ -170,6 +179,22 @@ class Searcher:
for area in automation.areas_in_automation(self.hass, automation_entity_id):
self._add_or_resolve("area", area)
+ if blueprint := automation.blueprint_in_automation(
+ self.hass, automation_entity_id
+ ):
+ self._add_or_resolve("automation_blueprint", blueprint)
+
+ @callback
+ def _resolve_automation_blueprint(self, blueprint_path) -> None:
+ """Resolve an automation blueprint.
+
+ Will only be called if blueprint is an entry point.
+ """
+ for entity_id in automation.automations_with_blueprint(
+ self.hass, blueprint_path
+ ):
+ self._add_or_resolve("automation", entity_id)
+
@callback
def _resolve_config_entry(self, config_entry_id) -> None:
"""Resolve a config entry.
@@ -289,3 +314,15 @@ class Searcher:
for area in script.areas_in_script(self.hass, script_entity_id):
self._add_or_resolve("area", area)
+
+ if blueprint := script.blueprint_in_script(self.hass, script_entity_id):
+ self._add_or_resolve("script_blueprint", blueprint)
+
+ @callback
+ def _resolve_script_blueprint(self, blueprint_path) -> None:
+ """Resolve a script blueprint.
+
+ Will only be called if blueprint is an entry point.
+ """
+ for entity_id in script.scripts_with_blueprint(self.hass, blueprint_path):
+ self._add_or_resolve("script", entity_id)
diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json
index 9080b940b2a5..53441d365b4f 100644
--- a/homeassistant/components/select/strings.json
+++ b/homeassistant/components/select/strings.json
@@ -24,5 +24,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py
index 9a3bb8bc3f0e..d6679d80f69b 100644
--- a/homeassistant/components/sense/sensor.py
+++ b/homeassistant/components/sense/sensor.py
@@ -210,6 +210,7 @@ class SenseVoltageSensor(SensorEntity):
"""Implementation of a Sense energy voltage sensor."""
_attr_device_class = SensorDeviceClass.VOLTAGE
+ _attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_attribution = ATTRIBUTION
_attr_should_poll = False
diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json
index 729299bb8fa0..f99792f7dc1d 100644
--- a/homeassistant/components/sensibo/manifest.json
+++ b/homeassistant/components/sensibo/manifest.json
@@ -15,5 +15,5 @@
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
"quality_scale": "platinum",
- "requirements": ["pysensibo==1.0.25"]
+ "requirements": ["pysensibo==1.0.28"]
}
diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py
index 8d0dc5fba2f6..ee9c946268fe 100644
--- a/homeassistant/components/sensibo/switch.py
+++ b/homeassistant/components/sensibo/switch.py
@@ -138,7 +138,7 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):
await func(
self,
key=self.entity_description.data_key,
- value=True,
+ value=False,
)
@property
@@ -149,27 +149,25 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):
return None
@async_handle_api_call
- async def async_turn_on_timer(self, key: str, value: Any) -> bool:
+ async def async_turn_on_timer(self, key: str, value: bool) -> bool:
"""Make service call to api for setting timer."""
- new_state = bool(self.device_data.ac_states["on"] is False)
data = {
"minutesFromNow": 60,
- "acState": {**self.device_data.ac_states, "on": new_state},
+ "acState": {**self.device_data.ac_states, "on": value},
}
result = await self._client.async_set_timer(self._device_id, data)
return bool(result.get("status") == "success")
@async_handle_api_call
- async def async_turn_off_timer(self, key: str, value: Any) -> bool:
+ async def async_turn_off_timer(self, key: str, value: bool) -> bool:
"""Make service call to api for deleting timer."""
result = await self._client.async_del_timer(self._device_id)
return bool(result.get("status") == "success")
@async_handle_api_call
- async def async_turn_on_off_pure_boost(self, key: str, value: Any) -> bool:
+ async def async_turn_on_off_pure_boost(self, key: str, value: bool) -> bool:
"""Make service call to api for setting Pure Boost."""
- new_state = bool(self.device_data.pure_boost_enabled is False)
- data: dict[str, Any] = {"enabled": new_state}
+ data: dict[str, Any] = {"enabled": value}
if self.device_data.pure_measure_integration is None:
data["sensitivity"] = "N"
data["measurementsIntegration"] = True
@@ -180,14 +178,13 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):
return bool(result.get("status") == "success")
@async_handle_api_call
- async def async_turn_on_off_smart(self, key: str, value: Any) -> bool:
+ async def async_turn_on_off_smart(self, key: str, value: bool) -> bool:
"""Make service call to api for setting Climate React."""
if self.device_data.smart_type is None:
raise HomeAssistantError(
"Use Sensibo Enable Climate React Service once to enable switch or the"
" Sensibo app"
)
- new_state = bool(self.device_data.smart_on is False)
- data: dict[str, Any] = {"enabled": new_state}
+ data: dict[str, Any] = {"enabled": value}
result = await self._client.async_enable_climate_react(self._device_id, data)
return bool(result.get("status") == "success")
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index d0fdc8a08864..f21f57d9d36d 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -548,7 +548,9 @@ class SensorEntity(Entity):
) from err
# Enum checks
- if device_class == SensorDeviceClass.ENUM or self.options is not None:
+ if (
+ options := self.options
+ ) is not None or device_class == SensorDeviceClass.ENUM:
if device_class != SensorDeviceClass.ENUM:
reason = "is missing the enum device class"
if device_class is not None:
@@ -557,7 +559,7 @@ class SensorEntity(Entity):
f"Sensor {self.entity_id} is providing enum options, but {reason}"
)
- if (options := self.options) and value not in options:
+ if options and value not in options:
raise ValueError(
f"Sensor {self.entity_id} provides state value '{value}', "
"which is not in the list of options provided"
@@ -581,11 +583,11 @@ class SensorEntity(Entity):
numerical_value = float(value) # type:ignore[arg-type]
except (TypeError, ValueError) as err:
raise ValueError(
- f"Sensor {self.entity_id} has device class {device_class}, "
- f"state class {state_class} unit {unit_of_measurement} and "
- f"suggested precision {suggested_precision} thus indicating it "
+ f"Sensor {self.entity_id} has device class '{device_class}', "
+ f"state class '{state_class}' unit '{unit_of_measurement}' and "
+ f"suggested precision '{suggested_precision}' thus indicating it "
f"has a numeric value; however, it has the non-numeric value: "
- f"{value} ({type(value)})"
+ f"'{value}' ({type(value)})"
) from err
else:
numerical_value = value
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index e829c8a8e494..17155912e482 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.backports.enum import StrEnum
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
@@ -347,6 +348,12 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `µg/m³`
"""
+ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
+ """Ratio of VOC.
+
+ Unit of measurement: `ppm`, `ppb`
+ """
+
VOLTAGE = "voltage"
"""Voltage.
@@ -443,8 +450,6 @@ STATE_CLASS_TOTAL: Final = "total"
STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing"
STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
-# Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in
-# `entity-registry-settings.ts`
UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = {
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
SensorDeviceClass.CURRENT: ElectricCurrentConverter,
@@ -461,6 +466,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter,
SensorDeviceClass.VOLTAGE: ElectricPotentialConverter,
SensorDeviceClass.VOLUME: VolumeConverter,
SensorDeviceClass.VOLUME_STORAGE: VolumeConverter,
@@ -523,6 +529,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
},
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
+ },
SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential),
SensorDeviceClass.VOLUME: set(UnitOfVolume),
SensorDeviceClass.WATER: {
@@ -582,6 +592,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.TIMESTAMP: set(),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT},
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.VOLUME: {
SensorStateClass.TOTAL,
diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index 8547827d7488..c52e076e51ea 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -70,6 +70,7 @@ CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide"
CONF_IS_TEMPERATURE = "is_temperature"
CONF_IS_VALUE = "is_value"
CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds"
+CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts"
CONF_IS_VOLTAGE = "is_voltage"
CONF_IS_VOLUME = "is_volume"
CONF_IS_WATER = "is_water"
@@ -120,6 +121,9 @@ ENTITY_CONDITIONS = {
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [
{CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS}
],
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [
+ {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS}
+ ],
SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}],
SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}],
SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}],
@@ -173,6 +177,7 @@ CONDITION_SCHEMA = vol.All(
CONF_IS_SULPHUR_DIOXIDE,
CONF_IS_TEMPERATURE,
CONF_IS_VOLATILE_ORGANIC_COMPOUNDS,
+ CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
CONF_IS_VOLTAGE,
CONF_IS_VOLUME,
CONF_IS_WATER,
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index 3b2a0485554a..0d594e1b7c3a 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -69,6 +69,7 @@ CONF_SULPHUR_DIOXIDE = "sulphur_dioxide"
CONF_TEMPERATURE = "temperature"
CONF_VALUE = "value"
CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
+CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
CONF_VOLTAGE = "voltage"
CONF_VOLUME = "volume"
CONF_WATER = "water"
@@ -119,6 +120,9 @@ ENTITY_TRIGGERS = {
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [
{CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS}
],
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [
+ {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS}
+ ],
SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}],
SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}],
SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}],
@@ -173,6 +177,7 @@ TRIGGER_SCHEMA = vol.All(
CONF_SULPHUR_DIOXIDE,
CONF_TEMPERATURE,
CONF_VOLATILE_ORGANIC_COMPOUNDS,
+ CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
CONF_VOLTAGE,
CONF_VOLUME,
CONF_WATER,
diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py
index 21fbf453ac3f..f9fdc2525371 100644
--- a/homeassistant/components/sensor/recorder.py
+++ b/homeassistant/components/sensor/recorder.py
@@ -557,8 +557,11 @@ def _compile_statistics( # noqa: C901
last_stat = last_stats[entity_id][0]
last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"])
old_last_reset = last_reset
- new_state = old_state = last_stat["state"]
- _sum = last_stat["sum"] or 0.0
+ # If there are no previous values and has_sum
+ # was previously false there will be no last_stat
+ # for state or sum
+ new_state = old_state = last_stat.get("state")
+ _sum = last_stat.get("sum") or 0.0
for fstate, state in valid_float_states:
reset = False
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index 52792e1d1f28..d3dbbc678b07 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -41,6 +41,7 @@
"is_temperature": "Current {entity_name} temperature",
"is_value": "Current {entity_name} value",
"is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level",
+ "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]",
"is_voltage": "Current {entity_name} voltage",
"is_volume": "Current {entity_name} volume",
"is_water": "Current {entity_name} water",
@@ -87,6 +88,7 @@
"temperature": "{entity_name} temperature changes",
"value": "{entity_name} value changes",
"volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes",
+ "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]",
"voltage": "{entity_name} voltage changes",
"volume": "{entity_name} volume changes",
"water": "{entity_name} water changes",
@@ -244,6 +246,9 @@
"volatile_organic_compounds": {
"name": "VOCs"
},
+ "volatile_organic_compounds_parts": {
+ "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]"
+ },
"voltage": {
"name": "Voltage"
},
@@ -262,5 +267,11 @@
"wind_speed": {
"name": "Wind speed"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json
index 924c660da158..d684ee345b1b 100644
--- a/homeassistant/components/sentry/manifest.json
+++ b/homeassistant/components/sentry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sentry",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["sentry-sdk==1.20.0"]
+ "requirements": ["sentry-sdk==1.23.1"]
}
diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py
index 597ff953656a..85207ee4475d 100644
--- a/homeassistant/components/shelly/coordinator.py
+++ b/homeassistant/components/shelly/coordinator.py
@@ -97,7 +97,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]):
immediate=False,
function=self._async_reload_entry,
)
- entry.async_on_unload(self._debounced_reload.async_cancel)
+ entry.async_on_unload(self._debounced_reload.async_shutdown)
@property
def model(self) -> str:
diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py
index 5a00a1df0ddc..a89c74f9e509 100644
--- a/homeassistant/components/shelly/number.py
+++ b/homeassistant/components/shelly/number.py
@@ -30,7 +30,6 @@ from .entity import (
class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription):
"""Class to describe a BLOCK sensor."""
- mode: NumberMode = NumberMode("slider")
rest_path: str = ""
rest_arg: str = ""
@@ -46,7 +45,7 @@ NUMBERS: Final = {
native_min_value=0,
native_max_value=100,
native_step=1,
- mode=NumberMode("slider"),
+ mode=NumberMode.SLIDER,
rest_path="thermostat/0",
rest_arg="pos",
),
@@ -86,6 +85,7 @@ async def async_setup_entry(
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity):
"""Represent a block sleeping number."""
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index b83131a1004c..4a88157efc64 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -584,6 +584,14 @@ RPC_SENSORS: Final = {
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
+ "illuminance": RpcSensorDescription(
+ key="illuminance",
+ sub_key="lux",
+ name="Illuminance",
+ native_unit_of_measurement=LIGHT_LUX,
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
"temperature": RpcSensorDescription(
key="switch",
sub_key="temperature",
@@ -768,6 +776,7 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
return self.attribute_value
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity):
"""Represent a block sleeping sensor."""
@@ -808,6 +817,7 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity):
return self.last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, SensorEntity):
"""Represent a RPC sleeping sensor."""
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index 55c08620e817..c929e152b3fa 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -348,7 +348,7 @@ def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool:
def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool:
"""Return true if rpc channel consumption type is set to light."""
- con_types = config["sys"]["ui_data"].get("consumption_types")
+ con_types = config["sys"].get("ui_data", {}).get("consumption_types")
return con_types is not None and con_types[channel].lower().startswith("light")
diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py
index 6b26eb7854be..d31dc5da2828 100644
--- a/homeassistant/components/simplisafe/binary_sensor.py
+++ b/homeassistant/components/simplisafe/binary_sensor.py
@@ -27,6 +27,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [
DeviceTypes.MOTION,
DeviceTypes.SIREN,
DeviceTypes.SMOKE,
+ DeviceTypes.SMOKE_AND_CARBON_MONOXIDE,
DeviceTypes.TEMPERATURE,
]
@@ -38,6 +39,9 @@ TRIGGERED_SENSOR_TYPES = {
DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION,
DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY,
DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE,
+ # Although this sensor can technically apply to both smoke and carbon, we use the
+ # SMOKE device class for simplicity:
+ DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: BinarySensorDeviceClass.SMOKE,
}
diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json
index 184e1f306e0f..d137824b3dbf 100644
--- a/homeassistant/components/simplisafe/manifest.json
+++ b/homeassistant/components/simplisafe/manifest.json
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["simplipy"],
- "requirements": ["simplisafe-python==2023.04.0"]
+ "requirements": ["simplisafe-python==2023.05.0"]
}
diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json
index 60d8843c1515..c3dde16a99fe 100644
--- a/homeassistant/components/siren/strings.json
+++ b/homeassistant/components/siren/strings.json
@@ -13,5 +13,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py
index 35de1a5b57bf..ac948408a3f1 100644
--- a/homeassistant/components/skybell/__init__.py
+++ b/homeassistant/components/skybell/__init__.py
@@ -11,8 +11,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import SkybellDataUpdateCoordinator
@@ -26,24 +24,6 @@ PLATFORMS = [
]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the SkyBell component."""
- hass.data.setdefault(DOMAIN, {})
-
- if DOMAIN in config:
- async_create_issue(
- hass,
- DOMAIN,
- "removed_yaml",
- breaks_in_ha_version="2022.9.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="removed_yaml",
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Skybell from a config entry."""
email = entry.data[CONF_EMAIL]
@@ -72,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for coordinator in device_coordinators
]
)
- hass.data[DOMAIN][entry.entry_id] = device_coordinators
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json
index f9122a1e1006..4289c3ed3c3a 100644
--- a/homeassistant/components/skybell/strings.json
+++ b/homeassistant/components/skybell/strings.json
@@ -24,11 +24,5 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
- },
- "issues": {
- "removed_yaml": {
- "title": "The Skybell YAML configuration has been removed",
- "description": "Configuring Skybell using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json
index 92816e94bf6f..2bd3476cbbe4 100644
--- a/homeassistant/components/slack/manifest.json
+++ b/homeassistant/components/slack/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "slack",
"name": "Slack",
- "codeowners": ["@bachya", "@tkdrob"],
+ "codeowners": ["@tkdrob"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/slack",
"integration_type": "service",
diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py
index 57a88f7a409f..7552f2c0697d 100644
--- a/homeassistant/components/smart_meter_texas/sensor.py
+++ b/homeassistant/components/smart_meter_texas/sensor.py
@@ -40,6 +40,7 @@ async def async_setup_entry(
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity):
"""Representation of an Smart Meter Texas sensor."""
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index 4f5d93969e89..40e236f91850 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -45,7 +45,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.air_conditioner_mode: [
@@ -55,7 +55,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.air_quality_sensor: [
@@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.dryer_operating_state: [
@@ -303,7 +303,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.oven_operating_state: [
@@ -362,7 +362,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.robot_cleaner_movement: [
@@ -382,7 +382,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.signal_strength: [
@@ -433,7 +433,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.thermostat_heating_setpoint: [
@@ -443,7 +443,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
SensorDeviceClass.TEMPERATURE,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.thermostat_mode: [
@@ -453,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.thermostat_operating_state: [
@@ -473,7 +473,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
SensorDeviceClass.TEMPERATURE,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.three_axis: [],
@@ -518,7 +518,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
None,
None,
None,
- EntityCategory.CONFIG,
+ EntityCategory.DIAGNOSTIC,
)
],
Capability.washer_operating_state: [
diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py
index e3cf1dcf2870..036fb6e1e90c 100644
--- a/homeassistant/components/smarty/__init__.py
+++ b/homeassistant/components/smarty/__init__.py
@@ -3,7 +3,7 @@ from datetime import timedelta
import ipaddress
import logging
-from pysmarty import Smarty
+from pysmarty import Smarty # pylint: disable=import-error
import voluptuous as vol
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py
index d9d757a71b51..baa25115186f 100644
--- a/homeassistant/components/smarty/binary_sensor.py
+++ b/homeassistant/components/smarty/binary_sensor.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
-from pysmarty import Smarty
+from pysmarty import Smarty # pylint: disable=import-error
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py
index cf4b49e6105d..cf7db560c153 100644
--- a/homeassistant/components/smarty/fan.py
+++ b/homeassistant/components/smarty/fan.py
@@ -5,7 +5,7 @@ import logging
import math
from typing import Any
-from pysmarty import Smarty
+from pysmarty import Smarty # pylint: disable=import-error
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant, callback
diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json
index cebb64863a00..8769aa666a78 100644
--- a/homeassistant/components/smarty/manifest.json
+++ b/homeassistant/components/smarty/manifest.json
@@ -2,6 +2,7 @@
"domain": "smarty",
"name": "Salda Smarty",
"codeowners": ["@z0mbieprocess"],
+ "disabled": "Dependencies not compatible with the new pip resolver",
"documentation": "https://www.home-assistant.io/integrations/smarty",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pysmarty"],
diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py
index 57d681594cf7..df99529b1f46 100644
--- a/homeassistant/components/smarty/sensor.py
+++ b/homeassistant/components/smarty/sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import datetime as dt
import logging
-from pysmarty import Smarty
+from pysmarty import Smarty # pylint: disable=import-error
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import UnitOfTemperature
diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py
index 309669a84968..d8ff55cc1753 100644
--- a/homeassistant/components/snapcast/__init__.py
+++ b/homeassistant/components/snapcast/__init__.py
@@ -27,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Could not connect to Snapcast server at {host}:{port}"
) from ex
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server)
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(
+ hass, server, f"{host}:{port}", entry.entry_id
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -37,5 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)
+ snapcast_data = hass.data[DOMAIN].pop(entry.entry_id)
+ # disconnect from server
+ await snapcast_data.disconnect()
return unload_ok
diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json
index 8701fca0ad46..f59283bb5f61 100644
--- a/homeassistant/components/snapcast/manifest.json
+++ b/homeassistant/components/snapcast/manifest.json
@@ -4,7 +4,7 @@
"codeowners": ["@luar123"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/snapcast",
- "iot_class": "local_polling",
+ "iot_class": "local_push",
"loggers": ["construct", "snapcast"],
- "requirements": ["snapcast==2.3.2"]
+ "requirements": ["snapcast==2.3.3"]
}
diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py
index 4fd7c587d40c..624bf7463ba0 100644
--- a/homeassistant/components/snapcast/media_player.py
+++ b/homeassistant/components/snapcast/media_player.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
-from snapcast.control.server import CONTROL_PORT
+from snapcast.control.server import CONTROL_PORT, Snapserver
import voluptuous as vol
from homeassistant.components.media_player import (
@@ -34,7 +34,6 @@ from .const import (
SERVICE_SNAPSHOT,
SERVICE_UNJOIN,
)
-from .server import HomeAssistantSnapcast
_LOGGER = logging.getLogger(__name__)
@@ -72,7 +71,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the snapcast config entry."""
- snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id]
+ snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server
register_services()
@@ -80,14 +79,18 @@ async def async_setup_entry(
port = config_entry.data[CONF_PORT]
hpid = f"{host}:{port}"
- snapcast_data.groups = [
- SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups
+ groups: list[MediaPlayerEntity] = [
+ SnapcastGroupDevice(group, hpid, config_entry.entry_id)
+ for group in snapcast_server.groups
]
- snapcast_data.clients = [
+ clients: list[MediaPlayerEntity] = [
SnapcastClientDevice(client, hpid, config_entry.entry_id)
- for client in snapcast_data.server.clients
+ for client in snapcast_server.clients
]
- async_add_entities(snapcast_data.clients + snapcast_data.groups)
+ async_add_entities(clients + groups)
+ hass.data[DOMAIN][
+ config_entry.entry_id
+ ].hass_async_add_entities = async_add_entities
async def async_setup_platform(
@@ -147,18 +150,27 @@ class SnapcastGroupDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
- def __init__(self, group, uid_part):
+ def __init__(self, group, uid_part, entry_id):
"""Initialize the Snapcast group device."""
+ self._attr_available = True
self._group = group
+ self._entry_id = entry_id
self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
async def async_added_to_hass(self) -> None:
"""Subscribe to group events."""
self._group.set_callback(self.schedule_update_ha_state)
+ self.hass.data[DOMAIN][self._entry_id].groups.append(self)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect group object when removed."""
self._group.set_callback(None)
+ self.hass.data[DOMAIN][self._entry_id].groups.remove(self)
+
+ def set_availability(self, available: bool) -> None:
+ """Set availability of group."""
+ self._attr_available = available
+ self.schedule_update_ha_state()
@property
def state(self) -> MediaPlayerState | None:
@@ -172,10 +184,15 @@ class SnapcastGroupDevice(MediaPlayerEntity):
"""Return the ID of snapcast group."""
return self._uid
+ @property
+ def identifier(self):
+ """Return the snapcast identifier."""
+ return self._group.identifier
+
@property
def name(self):
"""Return the name of the device."""
- return f"{GROUP_PREFIX}{self._group.identifier}"
+ return f"{self._group.friendly_name} {GROUP_SUFFIX}"
@property
def source(self):
@@ -197,12 +214,6 @@ class SnapcastGroupDevice(MediaPlayerEntity):
"""List of available input sources."""
return list(self._group.streams_by_name().keys())
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- name = f"{self._group.friendly_name} {GROUP_SUFFIX}"
- return {"friendly_name": name}
-
async def async_select_source(self, source: str) -> None:
"""Set input source."""
streams = self._group.streams_by_name()
@@ -242,6 +253,7 @@ class SnapcastClientDevice(MediaPlayerEntity):
def __init__(self, client, uid_part, entry_id):
"""Initialize the Snapcast client device."""
+ self._attr_available = True
self._client = client
self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
self._entry_id = entry_id
@@ -249,10 +261,17 @@ class SnapcastClientDevice(MediaPlayerEntity):
async def async_added_to_hass(self) -> None:
"""Subscribe to client events."""
self._client.set_callback(self.schedule_update_ha_state)
+ self.hass.data[DOMAIN][self._entry_id].clients.append(self)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect client object when removed."""
self._client.set_callback(None)
+ self.hass.data[DOMAIN][self._entry_id].clients.remove(self)
+
+ def set_availability(self, available: bool) -> None:
+ """Set availability of group."""
+ self._attr_available = available
+ self.schedule_update_ha_state()
@property
def unique_id(self):
@@ -270,7 +289,7 @@ class SnapcastClientDevice(MediaPlayerEntity):
@property
def name(self):
"""Return the name of the device."""
- return f"{CLIENT_PREFIX}{self._client.identifier}"
+ return f"{self._client.friendly_name} {CLIENT_SUFFIX}"
@property
def source(self):
@@ -307,8 +326,6 @@ class SnapcastClientDevice(MediaPlayerEntity):
state_attrs = {}
if self.latency is not None:
state_attrs["latency"] = self.latency
- name = f"{self._client.friendly_name} {CLIENT_SUFFIX}"
- state_attrs["friendly_name"] = name
return state_attrs
@property
diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py
index 507ad6393a2e..6a787dd5e883 100644
--- a/homeassistant/components/snapcast/server.py
+++ b/homeassistant/components/snapcast/server.py
@@ -1,15 +1,141 @@
"""Snapcast Integration."""
-from dataclasses import dataclass, field
+from __future__ import annotations
-from snapcast.control import Snapserver
+import logging
+
+import snapcast.control
+from snapcast.control.client import Snapclient
from homeassistant.components.media_player import MediaPlayerEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .media_player import SnapcastClientDevice, SnapcastGroupDevice
+
+_LOGGER = logging.getLogger(__name__)
-@dataclass
class HomeAssistantSnapcast:
- """Snapcast data stored in the Home Assistant data object."""
+ """Snapcast server and data stored in the Home Assistant data object."""
- server: Snapserver
- clients: list[MediaPlayerEntity] = field(default_factory=list)
- groups: list[MediaPlayerEntity] = field(default_factory=list)
+ hass: HomeAssistant
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ server: snapcast.control.Snapserver,
+ hpid: str,
+ entry_id: str,
+ ) -> None:
+ """Initialize the HomeAssistantSnapcast object.
+
+ Parameters
+ ----------
+ hass: HomeAssistant
+ hass object
+ server : snapcast.control.Snapserver
+ Snapcast server
+ hpid : str
+ host and port
+ entry_id: str
+ ConfigEntry entry_id
+
+ Returns
+ -------
+ None
+
+ """
+ self.hass: HomeAssistant = hass
+ self.server: snapcast.control.Snapserver = server
+ self.hpid: str = hpid
+ self._entry_id = entry_id
+ self.clients: list[SnapcastClientDevice] = []
+ self.groups: list[SnapcastGroupDevice] = []
+ self.hass_async_add_entities: AddEntitiesCallback
+ # connect callbacks
+ self.server.set_on_update_callback(self.on_update)
+ self.server.set_on_connect_callback(self.on_connect)
+ self.server.set_on_disconnect_callback(self.on_disconnect)
+ self.server.set_new_client_callback(self.on_add_client)
+
+ async def disconnect(self) -> None:
+ """Disconnect from server."""
+ self.server.set_on_update_callback(None)
+ self.server.set_on_connect_callback(None)
+ self.server.set_on_disconnect_callback(None)
+ self.server.set_new_client_callback(None)
+ await self.server.stop()
+
+ def on_update(self) -> None:
+ """Update all entities.
+
+ Retrieve all groups/clients from server and add/update/delete entities.
+ """
+ if not self.hass_async_add_entities:
+ return
+ new_groups: list[MediaPlayerEntity] = []
+ groups: list[MediaPlayerEntity] = []
+ hass_groups = {g.identifier: g for g in self.groups}
+ for group in self.server.groups:
+ if group.identifier in hass_groups:
+ groups.append(hass_groups[group.identifier])
+ hass_groups[group.identifier].async_schedule_update_ha_state()
+ else:
+ new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id))
+ new_clients: list[MediaPlayerEntity] = []
+ clients: list[MediaPlayerEntity] = []
+ hass_clients = {c.identifier: c for c in self.clients}
+ for client in self.server.clients:
+ if client.identifier in hass_clients:
+ clients.append(hass_clients[client.identifier])
+ hass_clients[client.identifier].async_schedule_update_ha_state()
+ else:
+ new_clients.append(
+ SnapcastClientDevice(client, self.hpid, self._entry_id)
+ )
+ del_entities: list[MediaPlayerEntity] = [
+ x for x in self.groups if x not in groups
+ ]
+ del_entities.extend([x for x in self.clients if x not in clients])
+
+ _LOGGER.debug("New clients: %s", str(new_clients))
+ _LOGGER.debug("New groups: %s", str(new_groups))
+ _LOGGER.debug("Delete: %s", str(del_entities))
+
+ ent_reg = er.async_get(self.hass)
+ for entity in del_entities:
+ ent_reg.async_remove(entity.entity_id)
+ self.hass_async_add_entities(new_clients + new_groups)
+
+ def on_connect(self) -> None:
+ """Activate all entities and update."""
+ for client in self.clients:
+ client.set_availability(True)
+ for group in self.groups:
+ group.set_availability(True)
+ _LOGGER.info("Server connected: %s", self.hpid)
+ self.on_update()
+
+ def on_disconnect(self, ex: Exception | None) -> None:
+ """Deactivate all entities."""
+ for client in self.clients:
+ client.set_availability(False)
+ for group in self.groups:
+ group.set_availability(False)
+ _LOGGER.warning(
+ "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "")
+ )
+
+ def on_add_client(self, client: Snapclient) -> None:
+ """Add a Snapcast client.
+
+ Parameters
+ ----------
+ client : Snapclient
+ Snapcast client to be added to HA.
+ """
+ if not self.hass_async_add_entities:
+ return
+ clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)]
+ self.hass_async_add_entities(clients)
diff --git a/homeassistant/components/songpal/const.py b/homeassistant/components/songpal/const.py
index f12b77800a9d..496618f35f03 100644
--- a/homeassistant/components/songpal/const.py
+++ b/homeassistant/components/songpal/const.py
@@ -3,3 +3,5 @@ DOMAIN = "songpal"
SET_SOUND_SETTING = "set_sound_setting"
CONF_ENDPOINT = "endpoint"
+
+ERROR_REQUEST_RETRY = 40000
diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py
index 6b7cf73b28b8..0d41aec699b7 100644
--- a/homeassistant/components/songpal/media_player.py
+++ b/homeassistant/components/songpal/media_player.py
@@ -34,7 +34,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING
+from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING
_LOGGER = logging.getLogger(__name__)
@@ -332,11 +332,27 @@ class SongpalEntity(MediaPlayerEntity):
async def async_turn_on(self) -> None:
"""Turn the device on."""
- return await self._dev.set_power(True)
+ try:
+ return await self._dev.set_power(True)
+ except SongpalException as ex:
+ if ex.code == ERROR_REQUEST_RETRY:
+ _LOGGER.debug(
+ "Swallowing %s, the device might be already in the wanted state", ex
+ )
+ return
+ raise
async def async_turn_off(self) -> None:
"""Turn the device off."""
- return await self._dev.set_power(False)
+ try:
+ return await self._dev.set_power(False)
+ except SongpalException as ex:
+ if ex.code == ERROR_REQUEST_RETRY:
+ _LOGGER.debug(
+ "Swallowing %s, the device might be already in the wanted state", ex
+ )
+ return
+ raise
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute the device."""
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
index ea0a16229c14..e6b328cbcb0f 100644
--- a/homeassistant/components/sonos/__init__.py
+++ b/homeassistant/components/sonos/__init__.py
@@ -368,7 +368,9 @@ class SonosDiscoveryManager:
self, now: datetime.datetime | None = None
) -> None:
"""Add and maintain Sonos devices from a manual configuration."""
- for host in self.hosts:
+
+ # Loop through each configured host and verify that Soco attributes are available for it.
+ for host in self.hosts.copy():
ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
soco = SoCo(ip_addr)
try:
@@ -376,7 +378,12 @@ class SonosDiscoveryManager:
sync_get_visible_zones,
soco,
)
- except (OSError, SoCoException, Timeout) as ex:
+ except (
+ OSError,
+ SoCoException,
+ Timeout,
+ asyncio.TimeoutError,
+ ) as ex:
if not self.hosts_in_error.get(ip_addr):
_LOGGER.warning(
"Could not get visible Sonos devices from %s: %s", ip_addr, ex
@@ -386,31 +393,30 @@ class SonosDiscoveryManager:
_LOGGER.debug(
"Could not get visible Sonos devices from %s: %s", ip_addr, ex
)
+ continue
- else:
- if self.hosts_in_error.pop(ip_addr, None):
- _LOGGER.info("Connection restablished to Sonos device %s", ip_addr)
- if new_hosts := {
- x.ip_address
- for x in visible_zones
- if x.ip_address not in self.hosts
- }:
- _LOGGER.debug("Adding to manual hosts: %s", new_hosts)
- self.hosts.update(new_hosts)
- async_dispatcher_send(
- self.hass,
- f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
- "manual zone scan",
- )
- break
+ if self.hosts_in_error.pop(ip_addr, None):
+ _LOGGER.info("Connection reestablished to Sonos device %s", ip_addr)
+ # Each speaker has the topology for other online speakers, so add them in here if they were not
+ # configured. The metadata is already in Soco for these.
+ if new_hosts := {
+ x.ip_address for x in visible_zones if x.ip_address not in self.hosts
+ }:
+ _LOGGER.debug("Adding to manual hosts: %s", new_hosts)
+ self.hosts.update(new_hosts)
- for host in self.hosts.copy():
- ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
if self.is_device_invisible(ip_addr):
_LOGGER.debug("Discarding %s from manual hosts", ip_addr)
self.hosts.discard(ip_addr)
- continue
+ # Loop through each configured host that is not in error. Send a discovery message
+ # if a speaker does not already exist, or ping the speaker if it is unavailable.
+ for host in self.hosts.copy():
+ ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
+ soco = SoCo(ip_addr)
+ # Skip hosts that are in error to avoid blocking call on soco.uuid in event loop
+ if self.hosts_in_error.get(ip_addr):
+ continue
known_speaker = next(
(
speaker
@@ -420,12 +426,28 @@ class SonosDiscoveryManager:
None,
)
if not known_speaker:
- await self._async_handle_discovery_message(
- soco.uid, ip_addr, "manual zone scan"
- )
+ try:
+ await self._async_handle_discovery_message(
+ soco.uid,
+ ip_addr,
+ "manual zone scan",
+ )
+ except (
+ OSError,
+ SoCoException,
+ Timeout,
+ asyncio.TimeoutError,
+ ) as ex:
+ _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex)
elif not known_speaker.available:
try:
await self.hass.async_add_executor_job(known_speaker.ping)
+ # Only send the message if the ping was successful.
+ async_dispatcher_send(
+ self.hass,
+ f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
+ "manual zone scan",
+ )
except SonosUpdateError:
_LOGGER.debug(
"Manual poll to %s failed, keeping unavailable", ip_addr
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 087c636f1ed8..fce34bde80a6 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco"],
- "requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"],
+ "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py
index c37738026503..d44d66bbd477 100644
--- a/homeassistant/components/speedtestdotnet/sensor.py
+++ b/homeassistant/components/speedtestdotnet/sensor.py
@@ -78,6 +78,7 @@ async def async_setup_entry(
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SpeedtestSensor(
CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity
):
diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py
index 7cbcbe73efaf..a6c526a6a7f4 100644
--- a/homeassistant/components/sql/config_flow.py
+++ b/homeassistant/components/sql/config_flow.py
@@ -114,9 +114,6 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
db_url = user_input.get(CONF_DB_URL)
query = user_input[CONF_QUERY]
column = user_input[CONF_COLUMN_NAME]
- uom = user_input.get(CONF_UNIT_OF_MEASUREMENT)
- value_template = user_input.get(CONF_VALUE_TEMPLATE)
- name = user_input[CONF_NAME]
db_url_for_validation = None
try:
@@ -133,22 +130,23 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except ValueError:
errors["query"] = "query_invalid"
- add_db_url = (
- {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {}
- )
+ options = {
+ CONF_QUERY: query,
+ CONF_COLUMN_NAME: column,
+ CONF_NAME: user_input[CONF_NAME],
+ }
+ if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT):
+ options[CONF_UNIT_OF_MEASUREMENT] = uom
+ if value_template := user_input.get(CONF_VALUE_TEMPLATE):
+ options[CONF_VALUE_TEMPLATE] = value_template
+ if db_url_for_validation != get_instance(self.hass).db_url:
+ options[CONF_DB_URL] = db_url_for_validation
if not errors:
return self.async_create_entry(
- title=name,
+ title=user_input[CONF_NAME],
data={},
- options={
- **add_db_url,
- CONF_QUERY: query,
- CONF_COLUMN_NAME: column,
- CONF_UNIT_OF_MEASUREMENT: uom,
- CONF_VALUE_TEMPLATE: value_template,
- CONF_NAME: name,
- },
+ options=options,
)
return self.async_show_form(
@@ -196,13 +194,21 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
db_url_for_validation,
recorder_db,
)
- if db_url and db_url_for_validation == recorder_db:
- user_input.pop(CONF_DB_URL)
+
+ options = {
+ CONF_QUERY: query,
+ CONF_COLUMN_NAME: column,
+ CONF_NAME: name,
+ }
+ if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT):
+ options[CONF_UNIT_OF_MEASUREMENT] = uom
+ if value_template := user_input.get(CONF_VALUE_TEMPLATE):
+ options[CONF_VALUE_TEMPLATE] = value_template
+ if db_url_for_validation != get_instance(self.hass).db_url:
+ options[CONF_DB_URL] = db_url_for_validation
+
return self.async_create_entry(
- data={
- CONF_NAME: name,
- **user_input,
- },
+ data=options,
)
return self.async_show_form(
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index 97eb337731a2..4d3e95d6b309 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -1,9 +1,9 @@
{
"domain": "sql",
"name": "SQL",
- "codeowners": ["@dgomes", "@gjohansson-ST", "@dougiteixeira"],
+ "codeowners": ["@gjohansson-ST", "@dougiteixeira"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
- "requirements": ["sqlalchemy==2.0.12"]
+ "requirements": ["sqlalchemy==2.0.15"]
}
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
index a2efc90d517b..43c2868dd69a 100644
--- a/homeassistant/components/squeezebox/manifest.json
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"iot_class": "local_polling",
"loggers": ["pysqueezebox"],
- "requirements": ["pysqueezebox==0.6.1"]
+ "requirements": ["pysqueezebox==0.6.3"]
}
diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py
index 570e79e49931..e448fe066c48 100644
--- a/homeassistant/components/ssdp/__init__.py
+++ b/homeassistant/components/ssdp/__init__.py
@@ -48,7 +48,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback as core_callback
from homeassistant.data_entry_flow import BaseServiceInfo
-from homeassistant.helpers import discovery_flow
+from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
@@ -106,6 +106,9 @@ PRIMARY_MATCH_KEYS = [
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
@dataclass(slots=True)
class SsdpServiceInfo(BaseServiceInfo):
"""Prepared info from ssdp/upnp entries."""
diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py
index 2b1b32232129..72f1cd2abb85 100644
--- a/homeassistant/components/steam_online/__init__.py
+++ b/homeassistant/components/steam_online/__init__.py
@@ -4,8 +4,6 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import SteamDataUpdateCoordinator
@@ -13,22 +11,6 @@ from .coordinator import SteamDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Steam integration."""
- if DOMAIN in config:
- async_create_issue(
- hass,
- DOMAIN,
- "removed_yaml",
- breaks_in_ha_version="2022.8.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="removed_yaml",
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Steam from a config entry."""
coordinator = SteamDataUpdateCoordinator(hass)
diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json
index 63dc7cce22a9..1b431795ea4e 100644
--- a/homeassistant/components/steam_online/strings.json
+++ b/homeassistant/components/steam_online/strings.json
@@ -35,11 +35,5 @@
"error": {
"unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends"
}
- },
- "issues": {
- "removed_yaml": {
- "title": "The Steam YAML configuration has been removed",
- "description": "Configuring Steam using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
}
}
diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json
index ef7a96e526ba..8b8e9b8a427f 100644
--- a/homeassistant/components/stream/manifest.json
+++ b/homeassistant/components/stream/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.0.0", "numpy==1.23.2"]
+ "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.1.0", "numpy==1.23.2"]
}
diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py
index 5e34a567c9ef..516cd4ddea10 100644
--- a/homeassistant/components/stt/__init__.py
+++ b/homeassistant/components/stt/__init__.py
@@ -413,7 +413,7 @@ def _metadata_from_header(request: web.Request) -> SpeechMetadata:
def websocket_list_engines(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
- """List speech to text engines and, optionally, if they support a given language."""
+ """List speech-to-text engines and, optionally, if they support a given language."""
component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN]
legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS]
diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py
index f2a5854e5678..9ee3f0bb8ad4 100644
--- a/homeassistant/components/stt/legacy.py
+++ b/homeassistant/components/stt/legacy.py
@@ -1,4 +1,4 @@
-"""Handle legacy speech to text platforms."""
+"""Handle legacy speech-to-text platforms."""
from __future__ import annotations
from abc import ABC, abstractmethod
@@ -51,7 +51,7 @@ def async_get_provider(
def async_setup_legacy(
hass: HomeAssistant, config: ConfigType
) -> list[Coroutine[Any, Any, None]]:
- """Set up legacy speech to text providers."""
+ """Set up legacy speech-to-text providers."""
providers = hass.data[DATA_PROVIDERS] = {}
async def async_setup_platform(p_type, p_config=None, discovery_info=None):
@@ -61,7 +61,7 @@ def async_setup_legacy(
platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type)
if platform is None:
- _LOGGER.error("Unknown speech to text platform specified")
+ _LOGGER.error("Unknown speech-to-text platform specified")
return
try:
diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json
index 73eb0fa4c079..53bb7fa19379 100644
--- a/homeassistant/components/stt/manifest.json
+++ b/homeassistant/components/stt/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "stt",
- "name": "Speech-to-Text (STT)",
+ "name": "Speech-to-text (STT)",
"codeowners": ["@home-assistant/core", "@pvizeli"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/stt",
diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py
index a43bf4fd8082..a2378136a06a 100644
--- a/homeassistant/components/sun/__init__.py
+++ b/homeassistant/components/sun/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
-from homeassistant.helpers import event
+from homeassistant.helpers import config_validation as cv, event
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.integration_platform import (
async_process_integration_platform_for_component,
@@ -80,6 +80,9 @@ _PHASE_UPDATES = {
}
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Track the state of the sun."""
hass.async_create_task(
diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py
index 8a390ea2a27c..344e0c2179ef 100644
--- a/homeassistant/components/sun/sensor.py
+++ b/homeassistant/components/sun/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import DEGREE
+from homeassistant.const import DEGREE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
@@ -118,6 +118,7 @@ class SunSensor(SensorEntity):
"""Representation of a Sun Sensor."""
_attr_has_entity_name = True
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: SunSensorEntityDescription
def __init__(
diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json
index a7934ba42092..2bb6c82a8c15 100644
--- a/homeassistant/components/switch/strings.json
+++ b/homeassistant/components/switch/strings.json
@@ -30,5 +30,11 @@
"outlet": {
"name": "Outlet"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py
index 352f191588ac..dee1fe5cd8f5 100644
--- a/homeassistant/components/switchbee/__init__.py
+++ b/homeassistant/components/switchbee/__init__.py
@@ -2,18 +2,27 @@
from __future__ import annotations
+import logging
+import re
+
+from aiohttp import ClientSession
from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api
from switchbee.api.central_unit import SwitchBeeError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.device_registry as dr
+import homeassistant.helpers.entity_registry as er
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
+_LOGGER = logging.getLogger(__name__)
+
+
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
@@ -23,18 +32,14 @@ PLATFORMS: list[Platform] = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up SwitchBee Smart Home from a config entry."""
+async def get_api_object(
+ central_unit: str, user: str, password: str, websession: ClientSession
+) -> CentralUnitPolling | CentralUnitWsRPC:
+ """Return SwitchBee API object."""
- hass.data.setdefault(DOMAIN, {})
- central_unit = entry.data[CONF_HOST]
- user = entry.data[CONF_USERNAME]
- password = entry.data[CONF_PASSWORD]
- websession = async_get_clientsession(hass, verify_ssl=False)
api: CentralUnitPolling | CentralUnitWsRPC = CentralUnitPolling(
central_unit, user, password, websession
)
-
# First try to connect and fetch the version
try:
await api.connect()
@@ -46,6 +51,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api = CentralUnitWsRPC(central_unit, user, password, websession)
await api.connect()
+ return api
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up SwitchBee Smart Home from a config entry."""
+
+ hass.data.setdefault(DOMAIN, {})
+ central_unit = entry.data[CONF_HOST]
+ user = entry.data[CONF_USERNAME]
+ password = entry.data[CONF_PASSWORD]
+ websession = async_get_clientsession(hass, verify_ssl=False)
+ api = await get_api_object(central_unit, user, password, websession)
+
coordinator = SwitchBeeCoordinator(
hass,
api,
@@ -71,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
+
+
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate old entry."""
+ _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+ if config_entry.version == 1:
+ dev_reg = dr.async_get(hass)
+ websession = async_get_clientsession(hass, verify_ssl=False)
+ old_unique_id = config_entry.unique_id
+ assert isinstance(old_unique_id, str)
+ api = await get_api_object(
+ config_entry.data[CONF_HOST],
+ config_entry.data[CONF_USERNAME],
+ config_entry.data[CONF_PASSWORD],
+ websession,
+ )
+ new_unique_id = api.unique_id
+
+ @callback
+ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
+ """Update unique ID of entity entry."""
+ if match := re.match(
+ rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id
+ ):
+ entity_new_unique_id = f'{new_unique_id}-{match.group("id")}'
+ _LOGGER.info(
+ "Migrating entity %s from %s to new id %s",
+ entity_entry.entity_id,
+ entity_entry.unique_id,
+ entity_new_unique_id,
+ )
+ return {"new_unique_id": entity_new_unique_id}
+
+ return None
+
+ if new_unique_id:
+ # Migrate devices
+ for device_entry in dr.async_entries_for_config_entry(
+ dev_reg, config_entry.entry_id
+ ):
+ assert isinstance(device_entry, dr.DeviceEntry)
+ for identifier in device_entry.identifiers:
+ if match := re.match(
+ rf"(?P.+)-{old_unique_id}$", identifier[1]
+ ):
+ new_identifiers = {
+ (
+ DOMAIN,
+ f"{match.group('id')}-{new_unique_id}",
+ )
+ }
+ _LOGGER.info(
+ "Migrating device %s identifiers from %s to %s",
+ device_entry.name,
+ device_entry.identifiers,
+ new_identifiers,
+ )
+ dev_reg.async_update_device(
+ device_entry.id, new_identifiers=new_identifiers
+ )
+
+ # Migrate entities
+ await er.async_migrate_entries(
+ hass, config_entry.entry_id, update_unique_id
+ )
+
+ config_entry.version = 2
+
+ _LOGGER.info("Migration to version %s successful", config_entry.version)
+
+ return True
diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py
index bf780e76eb0c..8dd740262f9a 100644
--- a/homeassistant/components/switchbee/climate.py
+++ b/homeassistant/components/switchbee/climate.py
@@ -103,7 +103,7 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate
# set HVAC capabilities
self._attr_max_temp = device.max_temperature
self._attr_min_temp = device.min_temperature
- self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.unit]
+ self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit]
self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes]
self._attr_hvac_modes.append(HVACMode.OFF)
self._update_attrs_from_coordinator()
diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py
index cb9cc27c1d05..8f109c7bf267 100644
--- a/homeassistant/components/switchbee/config_flow.py
+++ b/homeassistant/components/switchbee/config_flow.py
@@ -46,6 +46,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str:
raise CannotConnect from exp
+ if api.unique_id:
+ return api.unique_id
+
assert api.mac is not None
return format_mac(api.mac)
@@ -53,7 +56,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str:
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SwitchBee Smart Home."""
- VERSION = 1
+ VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -67,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
try:
- mac_formatted = await validate_input(self.hass, user_input)
+ unique_id = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -77,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
- await self.async_set_unique_id(mac_formatted)
+ await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py
index b1b606615dd5..49400e3c28d6 100644
--- a/homeassistant/components/switchbee/coordinator.py
+++ b/homeassistant/components/switchbee/coordinator.py
@@ -30,10 +30,12 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic
"""Initialize."""
self.api: CentralUnitPolling | CentralUnitWsRPC = swb_api
self._reconnect_counts: int = 0
- self.mac_formatted: str | None = (
- None if self.api.mac is None else format_mac(self.api.mac)
+ assert self.api.mac is not None
+ self.unique_id = (
+ self.api.unique_id
+ if self.api.unique_id is not None
+ else format_mac(self.api.mac)
)
-
super().__init__(
hass,
_LOGGER,
@@ -77,6 +79,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic
DeviceType.Shutter,
DeviceType.Somfy,
DeviceType.Thermostat,
+ DeviceType.VRFAC,
]
)
except SwitchBeeError as exp:
diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py
index 7e5df69fb6db..4f6a056202cb 100644
--- a/homeassistant/components/switchbee/entity.py
+++ b/homeassistant/components/switchbee/entity.py
@@ -3,7 +3,6 @@ import logging
from typing import Generic, TypeVar, cast
from switchbee import SWITCHBEE_BRAND
-from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError
from switchbee.device import DeviceType, SwitchBeeBaseDevice
from homeassistant.helpers.entity import DeviceInfo
@@ -32,7 +31,7 @@ class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTy
super().__init__(coordinator)
self._device = device
self._attr_name = device.name
- self._attr_unique_id = f"{coordinator.mac_formatted}-{device.id}"
+ self._attr_unique_id = f"{coordinator.unique_id}-{device.id}"
class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]):
@@ -54,7 +53,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]):
identifiers={
(
DOMAIN,
- f"{identifier}-{coordinator.mac_formatted}",
+ f"{identifier}-{coordinator.unique_id}",
)
},
manufacturer=SWITCHBEE_BRAND,
@@ -62,7 +61,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]):
suggested_area=device.zone,
via_device=(
DOMAIN,
- f"{coordinator.api.name} ({coordinator.api.mac})",
+ f"{coordinator.api.name} ({coordinator.api.unique_id})",
),
)
@@ -71,30 +70,8 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]):
"""Return True if entity is available."""
return self._is_online and super().available
- async def async_refresh_state(self) -> None:
- """Refresh the device state in the Central Unit.
-
- This function addresses issue of a device that came online back but still report
- unavailable state (-1).
- Such device (offline device) will keep reporting unavailable state (-1)
- until it has been actuated by the user (state changed to on/off).
-
- With this code we keep trying setting dummy state for the device
- in order for it to start reporting its real state back (assuming it came back online)
-
- """
-
- try:
- await self.coordinator.api.set_state(self._device.id, "dummy")
- except SwitchBeeDeviceOfflineError:
- return
- except SwitchBeeError:
- return
-
def _check_if_became_offline(self) -> None:
"""Check if the device was online (now offline), log message and mark it as Unavailable."""
- # This specific call will refresh the state of the device in the CU
- self.hass.async_create_task(self.async_refresh_state())
if self._is_online:
_LOGGER.warning(
diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json
index 3862539cb89d..2175f28eedec 100644
--- a/homeassistant/components/switchbee/manifest.json
+++ b/homeassistant/components/switchbee/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/switchbee",
"iot_class": "local_push",
- "requirements": ["pyswitchbee==1.7.19"]
+ "requirements": ["pyswitchbee==1.8.0"]
}
diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py
index 00b22d9428cb..0a0cf40ca8b0 100644
--- a/homeassistant/components/switchbot/binary_sensor.py
+++ b/homeassistant/components/switchbot/binary_sensor.py
@@ -57,7 +57,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
),
"unlocked_alarm": BinarySensorEntityDescription(
key="unlocked_alarm",
- translation_key="door_unclosed_alarm",
+ translation_key="door_unlocked_alarm",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
),
diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py
index abb18a19ed32..fb6ded993467 100644
--- a/homeassistant/components/switcher_kis/__init__.py
+++ b/homeassistant/components/switcher_kis/__init__.py
@@ -39,7 +39,7 @@ PLATFORMS = [
_LOGGER = logging.getLogger(__name__)
-CCONFIG_SCHEMA = vol.Schema(
+CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index 2be541c81061..823f2c5463f0 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "switcher_kis",
"name": "Switcher",
- "codeowners": ["@tomerfi", "@thecode"],
+ "codeowners": ["@thecode"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
"iot_class": "local_push",
diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py
index 3d149b3a40de..9a222d7096ca 100644
--- a/homeassistant/components/system_health/__init__.py
+++ b/homeassistant/components/system_health/__init__.py
@@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import aiohttp_client, integration_platform
+from homeassistant.helpers import (
+ aiohttp_client,
+ config_validation as cv,
+ integration_platform,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -24,6 +28,8 @@ DOMAIN = "system_health"
INFO_CALLBACK_TIMEOUT = 5
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@bind_hass
@callback
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index 691ca639656c..1cd21634c8e1 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -25,6 +25,7 @@ from .const import (
DATA,
DOMAIN,
INSIDE_TEMPERATURE_MEASUREMENT,
+ PRESET_AUTO,
SIGNAL_TADO_UPDATE_RECEIVED,
TEMP_OFFSET,
UPDATE_LISTENER,
@@ -151,6 +152,7 @@ class TadoConnector:
self.data = {
"device": {},
"weather": {},
+ "geofence": {},
"zone": {},
}
@@ -175,11 +177,7 @@ class TadoConnector:
"""Update the registered zones."""
self.update_devices()
self.update_zones()
- self.data["weather"] = self.tado.getWeather()
- dispatcher_send(
- self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"),
- )
+ self.update_home()
def update_devices(self):
"""Update the device data from Tado."""
@@ -250,10 +248,29 @@ class TadoConnector:
SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id),
)
+ def update_home(self):
+ """Update the home data from Tado."""
+ try:
+ self.data["weather"] = self.tado.getWeather()
+ self.data["geofence"] = self.tado.getHomeState()
+ dispatcher_send(
+ self.hass,
+ SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"),
+ )
+ except RuntimeError:
+ _LOGGER.error(
+ "Unable to connect to Tado while updating weather and geofence data"
+ )
+ return
+
def get_capabilities(self, zone_id):
"""Return the capabilities of the devices."""
return self.tado.getCapabilities(zone_id)
+ def get_auto_geofencing_supported(self):
+ """Return whether the Tado Home supports auto geofencing."""
+ return self.tado.getAutoGeofencingSupported()
+
def reset_zone_overlay(self, zone_id):
"""Reset the zone back to the default operation."""
self.tado.resetZoneOverlay(zone_id)
@@ -263,12 +280,17 @@ class TadoConnector:
self,
presence=PRESET_HOME,
):
- """Set the presence to home or away."""
+ """Set the presence to home, away or auto."""
if presence == PRESET_AWAY:
self.tado.setAway()
elif presence == PRESET_HOME:
self.tado.setHome()
+ elif presence == PRESET_AUTO:
+ self.tado.setAuto()
+
+ # Update everything when changing modes
self.update_zones()
+ self.update_home()
def set_zone_overlay(
self,
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
index cab3c42184e0..2b8bc4060d67 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -44,8 +44,10 @@ from .const import (
HA_TO_TADO_HVAC_MODE_MAP,
HA_TO_TADO_SWING_MODE_MAP,
ORDERED_KNOWN_TADO_MODES,
+ PRESET_AUTO,
SIGNAL_TADO_UPDATE_RECEIVED,
- SUPPORT_PRESET,
+ SUPPORT_PRESET_AUTO,
+ SUPPORT_PRESET_MANUAL,
TADO_HVAC_ACTION_TO_HA_HVAC_ACTION,
TADO_MODES_WITH_NO_TEMP_SETTING,
TADO_SWING_OFF,
@@ -245,6 +247,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self._attr_name = zone_name
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
+ self._attr_translation_key = DOMAIN
+
self._device_info = device_info
self._device_id = self._device_info["shortSerialNo"]
@@ -274,21 +278,31 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self._current_tado_swing_mode = TADO_SWING_OFF
self._tado_zone_data = None
+ self._tado_geofence_data = None
self._tado_zone_temp_offset = {}
+ self._async_update_home_data()
self._async_update_zone_data()
async def async_added_to_hass(self) -> None:
"""Register for sensor updates."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
+ self._async_update_home_callback,
+ )
+ )
+
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(
self._tado.home_id, "zone", self.zone_id
),
- self._async_update_callback,
+ self._async_update_zone_callback,
)
)
@@ -346,7 +360,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property
def preset_mode(self):
- """Return the current preset mode (home, away)."""
+ """Return the current preset mode (home, away or auto)."""
+
+ if "presenceLocked" in self._tado_geofence_data:
+ if not self._tado_geofence_data["presenceLocked"]:
+ return PRESET_AUTO
if self._tado_zone_data.is_away:
return PRESET_AWAY
return PRESET_HOME
@@ -354,7 +372,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property
def preset_modes(self):
"""Return a list of available preset modes."""
- return SUPPORT_PRESET
+ if self._tado.get_auto_geofencing_supported():
+ return SUPPORT_PRESET_AUTO
+ return SUPPORT_PRESET_MANUAL
def set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
@@ -501,11 +521,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
@callback
- def _async_update_callback(self):
+ def _async_update_zone_callback(self):
"""Load tado data and update state."""
self._async_update_zone_data()
self.async_write_ha_state()
+ @callback
+ def _async_update_home_data(self):
+ """Load tado geofencing data into zone."""
+ self._tado_geofence_data = self._tado.data["geofence"]
+
+ @callback
+ def _async_update_home_callback(self):
+ """Load tado data and update state."""
+ self._async_update_home_data()
+ self.async_write_ha_state()
+
def _normalize_target_temp_for_hvac_mode(self):
# Set a target temperature if we don't have any
# This can happen when we switch from Off to On
diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py
index 94d074c40662..9366a18b6fef 100644
--- a/homeassistant/components/tado/const.py
+++ b/homeassistant/components/tado/const.py
@@ -153,8 +153,14 @@ TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.
DEFAULT_TADO_PRECISION = 0.1
-SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME]
+# Constant for Auto Geolocation mode
+PRESET_AUTO = "auto"
+SUPPORT_PRESET_AUTO = [PRESET_AWAY, PRESET_HOME, PRESET_AUTO]
+SUPPORT_PRESET_MANUAL = [PRESET_AWAY, PRESET_HOME]
+
+SENSOR_DATA_CATEGORY_WEATHER = "weather"
+SENSOR_DATA_CATEGORY_GEOFENCE = "geofence"
TADO_SWING_OFF = "OFF"
TADO_SWING_ON = "ON"
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index 86ace76c8492..62f7a377239e 100644
--- a/homeassistant/components/tado/manifest.json
+++ b/homeassistant/components/tado/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "tado",
"name": "Tado",
- "codeowners": ["@michaelarnauts"],
+ "codeowners": ["@michaelarnauts", "@chiefdragon"],
"config_flow": true,
"dhcp": [
{
@@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
- "requirements": ["python-tado==0.12.0"]
+ "requirements": ["python-tado==0.15.0"]
}
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index d218e9ca9337..7742f6b0dca3 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -23,6 +23,8 @@ from .const import (
CONDITIONS_MAP,
DATA,
DOMAIN,
+ SENSOR_DATA_CATEGORY_GEOFENCE,
+ SENSOR_DATA_CATEGORY_WEATHER,
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
@@ -47,6 +49,7 @@ class TadoSensorEntityDescription(
"""Describes Tado sensor entity."""
attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None
+ data_category: str | None = None
HOME_SENSORS = [
@@ -60,6 +63,7 @@ HOME_SENSORS = [
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
+ data_category=SENSOR_DATA_CATEGORY_WEATHER,
),
TadoSensorEntityDescription(
key="solar percentage",
@@ -70,12 +74,35 @@ HOME_SENSORS = [
},
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
+ data_category=SENSOR_DATA_CATEGORY_WEATHER,
),
TadoSensorEntityDescription(
key="weather condition",
name="Weather condition",
state_fn=lambda data: format_condition(data["weatherState"]["value"]),
attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]},
+ data_category=SENSOR_DATA_CATEGORY_WEATHER,
+ ),
+ TadoSensorEntityDescription(
+ key="tado mode",
+ name="Tado mode",
+ # pylint: disable=unnecessary-lambda
+ state_fn=lambda data: get_tado_mode(data),
+ data_category=SENSOR_DATA_CATEGORY_GEOFENCE,
+ ),
+ TadoSensorEntityDescription(
+ key="geofencing mode",
+ name="Geofencing mode",
+ # pylint: disable=unnecessary-lambda
+ state_fn=lambda data: get_geofencing_mode(data),
+ data_category=SENSOR_DATA_CATEGORY_GEOFENCE,
+ ),
+ TadoSensorEntityDescription(
+ key="automatic geofencing",
+ name="Automatic geofencing",
+ # pylint: disable=unnecessary-lambda
+ state_fn=lambda data: get_automatic_geofencing(data),
+ data_category=SENSOR_DATA_CATEGORY_GEOFENCE,
),
]
@@ -145,6 +172,39 @@ def format_condition(condition: str) -> str:
return condition
+def get_tado_mode(data) -> str | None:
+ """Return Tado Mode based on Presence attribute."""
+ if "presence" in data:
+ return data["presence"]
+ return None
+
+
+def get_automatic_geofencing(data) -> bool:
+ """Return whether Automatic Geofencing is enabled based on Presence Locked attribute."""
+ if "presenceLocked" in data:
+ if data["presenceLocked"]:
+ return False
+ return True
+ return False
+
+
+def get_geofencing_mode(data) -> str:
+ """Return Geofencing Mode based on Presence and Presence Locked attributes."""
+ tado_mode = ""
+ tado_mode = data.get("presence", "unknown")
+
+ geofencing_switch_mode = ""
+ if "presenceLocked" in data:
+ if data["presenceLocked"]:
+ geofencing_switch_mode = "manual"
+ else:
+ geofencing_switch_mode = "auto"
+ else:
+ geofencing_switch_mode = "manual"
+
+ return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})"
+
+
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
@@ -200,9 +260,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(
- self._tado.home_id, "weather", "data"
- ),
+ SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
self._async_update_callback,
)
)
@@ -219,13 +277,19 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
"""Handle update callbacks."""
try:
tado_weather_data = self._tado.data["weather"]
+ tado_geofence_data = self._tado.data["geofence"]
except KeyError:
return
- self._attr_native_value = self.entity_description.state_fn(tado_weather_data)
+ if self.entity_description.data_category is not None:
+ if self.entity_description.data_category == SENSOR_DATA_CATEGORY_WEATHER:
+ tado_sensor_data = tado_weather_data
+ else:
+ tado_sensor_data = tado_geofence_data
+ self._attr_native_value = self.entity_description.state_fn(tado_sensor_data)
if self.entity_description.attributes_fn is not None:
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
- tado_weather_data
+ tado_sensor_data
)
diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json
index e1bf1a1406d6..3decfe3cd0c9 100644
--- a/homeassistant/components/tado/strings.json
+++ b/homeassistant/components/tado/strings.json
@@ -29,5 +29,18 @@
"title": "Adjust Tado options."
}
}
+ },
+ "entity": {
+ "climate": {
+ "tado": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "auto": "Auto"
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
index cd0dd00afe59..e82083f73ec1 100644
--- a/homeassistant/components/tag/__init__.py
+++ b/homeassistant/components/tag/__init__.py
@@ -38,6 +38,8 @@ UPDATE_FIELDS = {
vol.Optional(LAST_SCANNED): cv.datetime,
}
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
class TagIDExistsError(HomeAssistantError):
"""Raised when an item is not found."""
diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py
index 9b190855ad2d..98c7d1355c32 100644
--- a/homeassistant/components/tasmota/device_automation.py
+++ b/homeassistant/components/tasmota/device_automation.py
@@ -5,7 +5,7 @@ from hatasmota.models import DiscoveryHashType
from hatasmota.trigger import TasmotaTrigger
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import Event, HomeAssistant
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -24,10 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N
async def async_device_removed(event: Event) -> None:
"""Handle the removal of a device."""
- if event.data["action"] != "remove":
- return
await async_remove_automations(hass, event.data["device_id"])
+ @callback
+ def _async_device_removed_filter(event: Event) -> bool:
+ """Filter device registry events."""
+ return event.data["action"] == "remove"
+
async def async_discover(
tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType
) -> None:
@@ -45,5 +48,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N
async_discover,
)
hass.data[DATA_UNSUB].append(
- hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed)
+ hass.bus.async_listen(
+ EVENT_DEVICE_REGISTRY_UPDATED,
+ async_device_removed,
+ event_filter=_async_device_removed_filter,
+ )
)
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index a453a4611bf6..0bb1efc6b124 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -62,6 +62,8 @@ ATTR_FILE = "file"
ATTR_FROM_FIRST = "from_first"
ATTR_FROM_LAST = "from_last"
ATTR_KEYBOARD = "keyboard"
+ATTR_RESIZE_KEYBOARD = "resize_keyboard"
+ATTR_ONE_TIME_KEYBOARD = "one_time_keyboard"
ATTR_KEYBOARD_INLINE = "inline_keyboard"
ATTR_MESSAGEID = "message_id"
ATTR_MSG = "message"
@@ -157,6 +159,8 @@ BASE_SERVICE_SCHEMA = vol.Schema(
vol.Optional(ATTR_PARSER): cv.string,
vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
+ vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean,
+ vol.Optional(ATTR_ONE_TIME_KEYBOARD): cv.boolean,
vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
vol.Optional(ATTR_TIMEOUT): cv.positive_int,
@@ -581,7 +585,13 @@ class TelegramNotificationService:
keys = keys if isinstance(keys, list) else [keys]
if keys:
params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup(
- [[key.strip() for key in row.split(",")] for row in keys]
+ [[key.strip() for key in row.split(",")] for row in keys],
+ resize_keyboard=data[ATTR_RESIZE_KEYBOARD]
+ if ATTR_RESIZE_KEYBOARD in data
+ else False,
+ one_time_keyboard=data[ATTR_ONE_TIME_KEYBOARD]
+ if ATTR_ONE_TIME_KEYBOARD in data
+ else False,
)
else:
params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True)
diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json
index 034f1ab315b8..d8f55dbe4e79 100644
--- a/homeassistant/components/text/strings.json
+++ b/homeassistant/components/text/strings.json
@@ -27,5 +27,11 @@
}
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py
index 4fc88479818d..dd2527763ad6 100644
--- a/homeassistant/components/thread/__init__.py
+++ b/homeassistant/components/thread/__init__.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -22,6 +23,8 @@ __all__ = [
"async_get_preferred_dataset",
]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Thread integration."""
diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py
index bcadf3ca5fbe..643981e763b7 100644
--- a/homeassistant/components/thread/dataset_store.py
+++ b/homeassistant/components/thread/dataset_store.py
@@ -1,13 +1,14 @@
"""Persistently store thread datasets."""
from __future__ import annotations
-from contextlib import suppress
import dataclasses
from datetime import datetime
from functools import cached_property
+import logging
from typing import Any, cast
from python_otbr_api import tlv_parser
+from python_otbr_api.tlv_parser import MeshcopTLVType
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -18,9 +19,11 @@ from homeassistant.util import dt as dt_util, ulid as ulid_util
DATA_STORE = "thread.datasets"
STORAGE_KEY = "thread.datasets"
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 1
+STORAGE_VERSION_MINOR = 2
SAVE_DELAY = 10
+_LOGGER = logging.getLogger(__name__)
+
class DatasetPreferredError(HomeAssistantError):
"""Raised when attempting to delete the preferred dataset."""
@@ -39,31 +42,31 @@ class DatasetEntry:
@property
def channel(self) -> int | None:
"""Return channel as an integer."""
- if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None:
+ if (channel := self.dataset.get(MeshcopTLVType.CHANNEL)) is None:
return None
- with suppress(ValueError):
- return int(channel, 16)
- return None
+ return cast(tlv_parser.Channel, channel).channel
@cached_property
- def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]:
+ def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]:
"""Return the dataset in dict format."""
return tlv_parser.parse_tlv(self.tlv)
@property
- def extended_pan_id(self) -> str | None:
+ def extended_pan_id(self) -> str:
"""Return extended PAN ID as a hex string."""
- return self.dataset.get(tlv_parser.MeshcopTLVType.EXTPANID)
+ return str(self.dataset[MeshcopTLVType.EXTPANID])
@property
def network_name(self) -> str | None:
"""Return network name as a string."""
- return self.dataset.get(tlv_parser.MeshcopTLVType.NETWORKNAME)
+ if (name := self.dataset.get(MeshcopTLVType.NETWORKNAME)) is None:
+ return None
+ return cast(tlv_parser.NetworkName, name).name
@property
def pan_id(self) -> str | None:
"""Return PAN ID as a hex string."""
- return self.dataset.get(tlv_parser.MeshcopTLVType.PANID)
+ return str(self.dataset.get(MeshcopTLVType.PANID))
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
@@ -75,6 +78,88 @@ class DatasetEntry:
}
+class DatasetStoreStore(Store):
+ """Store Thread datasets."""
+
+ async def _async_migrate_func(
+ self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
+ ) -> dict[str, Any]:
+ """Migrate to the new version."""
+ if old_major_version == 1:
+ if old_minor_version < 2:
+ datasets: dict[str, DatasetEntry] = {}
+ preferred_dataset = old_data["preferred_dataset"]
+
+ for dataset in old_data["datasets"]:
+ created = cast(datetime, dt_util.parse_datetime(dataset["created"]))
+ entry = DatasetEntry(
+ created=created,
+ id=dataset["id"],
+ source=dataset["source"],
+ tlv=dataset["tlv"],
+ )
+ if (
+ MeshcopTLVType.EXTPANID not in entry.dataset
+ or MeshcopTLVType.ACTIVETIMESTAMP not in entry.dataset
+ ):
+ _LOGGER.warning(
+ "Dropped invalid Thread dataset '%s'", entry.tlv
+ )
+ if entry.id == preferred_dataset:
+ preferred_dataset = None
+ continue
+
+ if entry.extended_pan_id in datasets:
+ if datasets[entry.extended_pan_id].id == preferred_dataset:
+ _LOGGER.warning(
+ (
+ "Dropped duplicated Thread dataset '%s' "
+ "(duplicate of preferred dataset '%s')"
+ ),
+ entry.tlv,
+ datasets[entry.extended_pan_id].tlv,
+ )
+ continue
+ new_timestamp = cast(
+ tlv_parser.Timestamp,
+ entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP],
+ )
+ old_timestamp = cast(
+ tlv_parser.Timestamp,
+ datasets[entry.extended_pan_id].dataset[
+ MeshcopTLVType.ACTIVETIMESTAMP
+ ],
+ )
+ if old_timestamp.seconds >= new_timestamp.seconds or (
+ old_timestamp.seconds == new_timestamp.seconds
+ and old_timestamp.ticks >= new_timestamp.ticks
+ ):
+ _LOGGER.warning(
+ (
+ "Dropped duplicated Thread dataset '%s' "
+ "(duplicate of '%s')"
+ ),
+ entry.tlv,
+ datasets[entry.extended_pan_id].tlv,
+ )
+ continue
+ _LOGGER.warning(
+ (
+ "Dropped duplicated Thread dataset '%s' "
+ "(duplicate of '%s')"
+ ),
+ datasets[entry.extended_pan_id].tlv,
+ entry.tlv,
+ )
+ datasets[entry.extended_pan_id] = entry
+ data = {
+ "preferred_dataset": preferred_dataset,
+ "datasets": [dataset.to_json() for dataset in datasets.values()],
+ }
+
+ return data
+
+
class DatasetStore:
"""Class to hold a collection of thread datasets."""
@@ -83,7 +168,7 @@ class DatasetStore:
self.hass = hass
self.datasets: dict[str, DatasetEntry] = {}
self._preferred_dataset: str | None = None
- self._store: Store[dict[str, Any]] = Store(
+ self._store: Store[dict[str, Any]] = DatasetStoreStore(
hass,
STORAGE_VERSION_MAJOR,
STORAGE_KEY,
@@ -96,10 +181,64 @@ class DatasetStore:
"""Add dataset, does nothing if it already exists."""
# Make sure the tlv is valid
dataset = tlv_parser.parse_tlv(tlv)
+
+ # Don't allow adding a dataset which does not have an extended pan id or
+ # timestamp
+ if (
+ MeshcopTLVType.EXTPANID not in dataset
+ or MeshcopTLVType.ACTIVETIMESTAMP not in dataset
+ ):
+ raise HomeAssistantError("Invalid dataset")
+
# Bail out if the dataset already exists
if any(entry for entry in self.datasets.values() if entry.dataset == dataset):
return
+ # Update if dataset with same extended pan id exists and the timestamp
+ # is newer
+ if entry := next(
+ (
+ entry
+ for entry in self.datasets.values()
+ if entry.dataset[MeshcopTLVType.EXTPANID]
+ == dataset[MeshcopTLVType.EXTPANID]
+ ),
+ None,
+ ):
+ new_timestamp = cast(
+ tlv_parser.Timestamp, dataset[MeshcopTLVType.ACTIVETIMESTAMP]
+ )
+ old_timestamp = cast(
+ tlv_parser.Timestamp,
+ entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP],
+ )
+ if old_timestamp.seconds >= new_timestamp.seconds or (
+ old_timestamp.seconds == new_timestamp.seconds
+ and old_timestamp.ticks >= new_timestamp.ticks
+ ):
+ _LOGGER.warning(
+ (
+ "Got dataset with same extended PAN ID and same or older active"
+ " timestamp, old dataset: '%s', new dataset: '%s'"
+ ),
+ entry.tlv,
+ tlv,
+ )
+ return
+ _LOGGER.debug(
+ (
+ "Updating dataset with same extended PAN ID and newer active "
+ "timestamp, old dataset: '%s', new dataset: '%s'"
+ ),
+ entry.tlv,
+ tlv,
+ )
+ self.datasets[entry.id] = dataclasses.replace(
+ self.datasets[entry.id], tlv=tlv
+ )
+ self.async_schedule_save()
+ return
+
entry = DatasetEntry(source=source, tlv=tlv)
self.datasets[entry.id] = entry
# Set to preferred if there is no preferred dataset
diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py
index 8dc5dd43041e..e6149214af4d 100644
--- a/homeassistant/components/thread/diagnostics.py
+++ b/homeassistant/components/thread/diagnostics.py
@@ -148,7 +148,8 @@ async def async_get_config_entry_diagnostics(
"unexpected_routers": set(),
},
)
- if mlp := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX):
+ if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX):
+ mlp = str(mlp_item)
network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}")
# Find all routes currently act that might be thread related, so we can match them to
diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json
index 3d61315f3d17..9a6a64481cd5 100644
--- a/homeassistant/components/thread/manifest.json
+++ b/homeassistant/components/thread/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"],
+ "requirements": ["python-otbr-api==2.1.0", "pyroute2==0.7.5"],
"zeroconf": ["_meshcop._udp.local."]
}
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index d6df026bbd7b..1b6c5e3045ac 100644
--- a/homeassistant/components/tibber/manifest.json
+++ b/homeassistant/components/tibber/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"quality_scale": "silver",
- "requirements": ["pyTibber==0.27.1"]
+ "requirements": ["pyTibber==0.27.2"]
}
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index a2f1db7536fa..242c2179a05c 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -606,7 +606,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
)
last_stats = await get_instance(self.hass).async_add_executor_job(
- get_last_statistics, self.hass, 1, statistic_id, True, {}
+ get_last_statistics, self.hass, 1, statistic_id, True, set()
)
if not last_stats:
diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py
new file mode 100644
index 000000000000..26d40191fb95
--- /dev/null
+++ b/homeassistant/components/time/__init__.py
@@ -0,0 +1,109 @@
+"""Component to allow setting time as platforms."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import time, timedelta
+import logging
+from typing import final
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_TIME
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa: F401
+ PLATFORM_SCHEMA,
+ PLATFORM_SCHEMA_BASE,
+)
+from homeassistant.helpers.entity import Entity, EntityDescription
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, SERVICE_SET_VALUE
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+ENTITY_ID_FORMAT = DOMAIN + ".{}"
+
+_LOGGER = logging.getLogger(__name__)
+
+__all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"]
+
+
+async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None:
+ """Service call wrapper to set a new date."""
+ return await entity.async_set_value(service_call.data[ATTR_TIME])
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Time entities."""
+ component = hass.data[DOMAIN] = EntityComponent[TimeEntity](
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL
+ )
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE, {vol.Required(ATTR_TIME): cv.time}, _async_set_value
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a config entry."""
+ component: EntityComponent[TimeEntity] = hass.data[DOMAIN]
+ return await component.async_setup_entry(entry)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ component: EntityComponent[TimeEntity] = hass.data[DOMAIN]
+ return await component.async_unload_entry(entry)
+
+
+@dataclass
+class TimeEntityDescription(EntityDescription):
+ """A class that describes time entities."""
+
+
+class TimeEntity(Entity):
+ """Representation of a Time entity."""
+
+ entity_description: TimeEntityDescription
+ _attr_native_value: time | None
+ _attr_device_class: None = None
+ _attr_state: None = None
+
+ @property
+ @final
+ def device_class(self) -> None:
+ """Return the device class for the entity."""
+ return None
+
+ @property
+ @final
+ def state_attributes(self) -> None:
+ """Return the state attributes."""
+ return None
+
+ @property
+ @final
+ def state(self) -> str | None:
+ """Return the entity state."""
+ if self.native_value is None:
+ return None
+ return self.native_value.isoformat()
+
+ @property
+ def native_value(self) -> time | None:
+ """Return the value reported by the time."""
+ return self._attr_native_value
+
+ def set_value(self, value: time) -> None:
+ """Change the time."""
+ raise NotImplementedError()
+
+ async def async_set_value(self, value: time) -> None:
+ """Change the time."""
+ await self.hass.async_add_executor_job(self.set_value, value)
diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py
new file mode 100644
index 000000000000..23901915c9f2
--- /dev/null
+++ b/homeassistant/components/time/const.py
@@ -0,0 +1,5 @@
+"""Provides the constants needed for the component."""
+
+DOMAIN = "time"
+
+SERVICE_SET_VALUE = "set_value"
diff --git a/homeassistant/components/time/manifest.json b/homeassistant/components/time/manifest.json
new file mode 100644
index 000000000000..f3c243e0005a
--- /dev/null
+++ b/homeassistant/components/time/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "time",
+ "name": "Time",
+ "codeowners": ["@home-assistant/core"],
+ "documentation": "https://www.home-assistant.io/integrations/time",
+ "integration_type": "entity",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/time/services.yaml b/homeassistant/components/time/services.yaml
new file mode 100644
index 000000000000..a8c843ab55ac
--- /dev/null
+++ b/homeassistant/components/time/services.yaml
@@ -0,0 +1,14 @@
+set_value:
+ name: Set Time
+ description: Set the time for a time entity.
+ target:
+ entity:
+ domain: time
+ fields:
+ time:
+ name: Time
+ description: The time to set.
+ required: true
+ example: "22:15"
+ selector:
+ time:
diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json
new file mode 100644
index 000000000000..9cbcf718d736
--- /dev/null
+++ b/homeassistant/components/time/strings.json
@@ -0,0 +1,14 @@
+{
+ "title": "Time",
+ "entity_component": {
+ "_": {
+ "name": "[%key:component::time::title%]"
+ }
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
+ }
+}
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index 7cb2c10425ec..3752f9c9cb5b 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
@@ -50,6 +51,7 @@ STATUS_PAUSED = "paused"
EVENT_TIMER_FINISHED = "timer.finished"
EVENT_TIMER_CANCELLED = "timer.cancelled"
+EVENT_TIMER_CHANGED = "timer.changed"
EVENT_TIMER_STARTED = "timer.started"
EVENT_TIMER_RESTARTED = "timer.restarted"
EVENT_TIMER_PAUSED = "timer.paused"
@@ -57,6 +59,7 @@ EVENT_TIMER_PAUSED = "timer.paused"
SERVICE_START = "start"
SERVICE_PAUSE = "pause"
SERVICE_CANCEL = "cancel"
+SERVICE_CHANGE = "change"
SERVICE_FINISH = "finish"
STORAGE_KEY = DOMAIN
@@ -158,6 +161,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause")
component.async_register_entity_service(SERVICE_CANCEL, {}, "async_cancel")
component.async_register_entity_service(SERVICE_FINISH, {}, "async_finish")
+ component.async_register_entity_service(
+ SERVICE_CHANGE,
+ {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
+ "async_change",
+ )
return True
@@ -321,6 +329,31 @@ class Timer(collection.CollectionEntity, RestoreEntity):
)
self.async_write_ha_state()
+ @callback
+ def async_change(self, duration: timedelta) -> None:
+ """Change duration of a running timer."""
+ if self._listener is None or self._end is None:
+ raise HomeAssistantError(
+ f"Timer {self.entity_id} is not running, only active timers can be changed"
+ )
+ if self._remaining and (self._remaining + duration) > self._duration:
+ raise HomeAssistantError(
+ f"Not possible to change timer {self.entity_id} beyond configured duration"
+ )
+ if self._remaining and (self._remaining + duration) < timedelta():
+ raise HomeAssistantError(
+ f"Not possible to change timer {self.entity_id} to negative time remaining"
+ )
+
+ self._listener()
+ self._end += duration
+ self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
+ self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
+ self._listener = async_track_point_in_utc_time(
+ self.hass, self._async_finished, self._end
+ )
+ self.async_write_ha_state()
+
@callback
def async_pause(self):
"""Pause a timer."""
diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml
index e17ea1dd5fb2..68caa44a6990 100644
--- a/homeassistant/components/timer/services.yaml
+++ b/homeassistant/components/timer/services.yaml
@@ -33,3 +33,18 @@ finish:
target:
entity:
domain: timer
+
+change:
+ name: Change
+ description: Change a timer
+ target:
+ entity:
+ domain: timer
+ fields:
+ duration:
+ description: Duration to add or subtract to the running timer
+ default: 0
+ required: true
+ example: "00:01:00, 60 or -60"
+ selector:
+ text:
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 98910d7af38e..544144018dd9 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -18,13 +18,13 @@ from homeassistant.components.calendar import (
CalendarEvent,
)
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import Event, HomeAssistant, ServiceCall
+from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
ALL_DAY,
@@ -120,7 +120,7 @@ async def async_setup_platform(
api = TodoistAPIAsync(token)
coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api)
- await coordinator.async_config_entry_first_refresh()
+ await coordinator.async_refresh()
async def _shutdown_coordinator(_: Event) -> None:
await coordinator.async_shutdown()
@@ -175,7 +175,7 @@ async def async_setup_platform(
)
)
- async_add_entities(project_devices)
+ async_add_entities(project_devices, update_before_add=True)
session = async_get_clientsession(hass)
@@ -214,14 +214,14 @@ async def async_setup_platform(
data["due_lang"] = call.data[DUE_DATE_LANG]
if DUE_DATE in call.data:
- due_date = dt.parse_datetime(call.data[DUE_DATE])
+ due_date = dt_util.parse_datetime(call.data[DUE_DATE])
if due_date is None:
- due = dt.parse_date(call.data[DUE_DATE])
+ due = dt_util.parse_date(call.data[DUE_DATE])
if due is None:
raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}")
due_date = datetime(due.year, due.month, due.day)
# Format it in the manner Todoist expects
- due_date = dt.as_utc(due_date)
+ due_date = dt_util.as_utc(due_date)
date_format = "%Y-%m-%dT%H:%M:%S"
data["due_datetime"] = datetime.strftime(due_date, date_format)
@@ -239,16 +239,16 @@ async def async_setup_platform(
_reminder_due["lang"] = call.data[REMINDER_DATE_LANG]
if REMINDER_DATE in call.data:
- due_date = dt.parse_datetime(call.data[REMINDER_DATE])
+ due_date = dt_util.parse_datetime(call.data[REMINDER_DATE])
if due_date is None:
- due = dt.parse_date(call.data[REMINDER_DATE])
+ due = dt_util.parse_date(call.data[REMINDER_DATE])
if due is None:
raise ValueError(
f"Invalid reminder_date: {call.data[REMINDER_DATE]}"
)
due_date = datetime(due.year, due.month, due.day)
# Format it in the manner Todoist expects
- due_date = dt.as_utc(due_date)
+ due_date = dt_util.as_utc(due_date)
date_format = "%Y-%m-%dT%H:%M:%S"
_reminder_due["date"] = datetime.strftime(due_date, date_format)
@@ -304,6 +304,12 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity
str(data[CONF_ID]) if data.get(CONF_ID) is not None else None
)
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self.data.update()
+ super()._handle_coordinator_update()
+
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
@@ -317,11 +323,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity
async def async_update(self) -> None:
"""Update all Todoist Calendars."""
await super().async_update()
- await self.data.async_update()
- # Set Todoist-specific data that can't easily be grabbed
- self._cal_data["all_tasks"] = [
- task[SUMMARY] for task in self.data.all_project_tasks
- ]
+ self.data.update()
async def async_get_events(
self,
@@ -342,7 +344,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity
return {
DUE_TODAY: self.data.event[DUE_TODAY],
OVERDUE: self.data.event[OVERDUE],
- ALL_TASKS: self._cal_data[ALL_TASKS],
+ ALL_TASKS: [task[SUMMARY] for task in self.data.all_project_tasks],
PRIORITY: self.data.event[PRIORITY],
LABELS: self.data.event[LABELS],
}
@@ -451,7 +453,7 @@ class TodoistProjectData:
LABELS: [],
OVERDUE: False,
PRIORITY: data.priority,
- START: dt.now(),
+ START: dt_util.now(),
SUMMARY: data.content,
}
@@ -472,19 +474,19 @@ class TodoistProjectData:
# complete the task.
# Generally speaking, that means right now.
if data.due is not None:
- end = dt.parse_datetime(
+ end = dt_util.parse_datetime(
data.due.datetime if data.due.datetime else data.due.date
)
- task[END] = dt.as_utc(end) if end is not None else end
+ task[END] = dt_util.as_local(end) if end is not None else end
if task[END] is not None:
if self._due_date_days is not None and (
- task[END] > dt.utcnow() + self._due_date_days
+ task[END] > dt_util.now() + self._due_date_days
):
# This task is out of range of our due date;
# it shouldn't be counted.
return None
- task[DUE_TODAY] = task[END].date() == dt.utcnow().date()
+ task[DUE_TODAY] = task[END].date() == dt_util.now().date()
# Special case: Task is overdue.
if task[END] <= task[START]:
@@ -610,7 +612,7 @@ class TodoistProjectData:
events.append(event)
return events
- async def async_update(self) -> None:
+ def update(self) -> None:
"""Get the latest data."""
tasks = self._coordinator.data
if self._id is None:
@@ -667,10 +669,10 @@ class TodoistProjectData:
def get_start(due: Due) -> datetime | date | None:
"""Return the task due date as a start date or date time."""
if due.datetime:
- start = dt.parse_datetime(due.datetime)
+ start = dt_util.parse_datetime(due.datetime)
if not start:
return None
- return dt.as_local(start)
+ return dt_util.as_local(start)
if due.date:
- return dt.parse_date(due.date)
+ return dt_util.parse_date(due.date)
return None
diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py
index 32e0b3573f5a..ef252d54e4e7 100644
--- a/homeassistant/components/totalconnect/binary_sensor.py
+++ b/homeassistant/components/totalconnect/binary_sensor.py
@@ -1,74 +1,78 @@
"""Interfaces with TotalConnect sensors."""
+import logging
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
+ BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
+LOW_BATTERY = "low_battery"
+TAMPER = "tamper"
+POWER = "power"
+ZONE = "zone"
+
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up TotalConnect device sensors based on a config entry."""
- sensors = []
+ sensors: list = []
client_locations = hass.data[DOMAIN][entry.entry_id].client.locations
for location_id, location in client_locations.items():
- for zone_id, zone in location.zones.items():
- sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone))
+ sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location))
+ sensors.append(TotalConnectAlarmTamperBinarySensor(location))
+ sensors.append(TotalConnectAlarmPowerBinarySensor(location))
+
+ for zone in location.zones.values():
+ sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone))
+
+ if not zone.is_type_button():
+ sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone))
+ sensors.append(TotalConnectTamperBinarySensor(location_id, zone))
async_add_entities(sensors, True)
-class TotalConnectBinarySensor(BinarySensorEntity):
+class TotalConnectZoneBinarySensor(BinarySensorEntity):
"""Represent an TotalConnect zone."""
- def __init__(self, zone_id, location_id, zone):
+ def __init__(self, location_id, zone):
"""Initialize the TotalConnect status."""
- self._zone_id = zone_id
self._location_id = location_id
self._zone = zone
- self._name = self._zone.description
- self._unique_id = f"{location_id} {zone_id}"
- self._is_on = None
- self._is_tampered = None
- self._is_low_battery = None
+ self._attr_name = f"{zone.description}{self.entity_description.name}"
+ self._attr_unique_id = (
+ f"{location_id}_{zone.zoneid}_{self.entity_description.key}"
+ )
+ self._attr_is_on = None
+ self._attr_extra_state_attributes = {
+ "zone_id": self._zone.zoneid,
+ "location_id": self._location_id,
+ "partition": self._zone.partition,
+ }
- @property
- def unique_id(self):
- """Return the unique id."""
- return self._unique_id
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
+class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor):
+ """Represent an TotalConnect security zone."""
- def update(self) -> None:
- """Return the state of the device."""
- self._is_tampered = self._zone.is_tampered()
- self._is_low_battery = self._zone.is_low_battery()
-
- if self._zone.is_faulted() or self._zone.is_triggered():
- self._is_on = True
- else:
- self._is_on = False
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self._is_on
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=ZONE, name=""
+ )
@property
def device_class(self):
- """Return the class of this device, from BinarySensorDeviceClass."""
- if self._zone.is_type_security():
- return BinarySensorDeviceClass.DOOR
+ """Return the class of this zone."""
if self._zone.is_type_fire():
return BinarySensorDeviceClass.SMOKE
if self._zone.is_type_carbon_monoxide():
@@ -77,16 +81,108 @@ class TotalConnectBinarySensor(BinarySensorEntity):
return BinarySensorDeviceClass.MOTION
if self._zone.is_type_medical():
return BinarySensorDeviceClass.SAFETY
+ # "security" type is a generic category so test for it last
+ if self._zone.is_type_security():
+ return BinarySensorDeviceClass.DOOR
+
+ _LOGGER.error(
+ "TotalConnect zone %s reported an unexpected device class",
+ self._zone.zoneid,
+ )
return None
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- attributes = {
- "zone_id": self._zone_id,
- "location_id": self._location_id,
- "low_battery": self._is_low_battery,
- "tampered": self._is_tampered,
- "partition": self._zone.partition,
+ def update(self):
+ """Return the state of the device."""
+ if self._zone.is_faulted() or self._zone.is_triggered():
+ self._attr_is_on = True
+ else:
+ self._attr_is_on = False
+
+
+class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor):
+ """Represent an TotalConnect zone low battery status."""
+
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=LOW_BATTERY,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ name=" low battery",
+ )
+
+ def update(self):
+ """Return the state of the device."""
+ self._attr_is_on = self._zone.is_low_battery()
+
+
+class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor):
+ """Represent an TotalConnect zone tamper status."""
+
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=TAMPER,
+ device_class=BinarySensorDeviceClass.TAMPER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ name=f" {TAMPER}",
+ )
+
+ def update(self):
+ """Return the state of the device."""
+ self._attr_is_on = self._zone.is_tampered()
+
+
+class TotalConnectAlarmBinarySensor(BinarySensorEntity):
+ """Represent an TotalConnect alarm device binary sensors."""
+
+ def __init__(self, location):
+ """Initialize the TotalConnect alarm device binary sensor."""
+ self._location = location
+ self._attr_name = f"{location.location_name}{self.entity_description.name}"
+ self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}"
+ self._attr_is_on = None
+ self._attr_extra_state_attributes = {
+ "location_id": self._location.location_id,
}
- return attributes
+
+
+class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor):
+ """Represent an TotalConnect Alarm low battery status."""
+
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=LOW_BATTERY,
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ name=" low battery",
+ )
+
+ def update(self):
+ """Return the state of the device."""
+ self._attr_is_on = self._location.is_low_battery()
+
+
+class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor):
+ """Represent an TotalConnect alarm tamper status."""
+
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=TAMPER,
+ device_class=BinarySensorDeviceClass.TAMPER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ name=f" {TAMPER}",
+ )
+
+ def update(self):
+ """Return the state of the device."""
+ self._attr_is_on = self._location.is_cover_tampered()
+
+
+class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor):
+ """Represent an TotalConnect alarm power status."""
+
+ entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription(
+ key=POWER,
+ device_class=BinarySensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ name=f" {POWER}",
+ )
+
+ def update(self):
+ """Return the state of the device."""
+ self._attr_is_on = not self._location.is_ac_loss()
diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py
index 824ea8df4239..1367f8757af6 100644
--- a/homeassistant/components/tplink_omada/__init__.py
+++ b/homeassistant/components/tplink_omada/__init__.py
@@ -18,7 +18,7 @@ from .config_flow import CONF_SITE, create_omada_client
from .const import DOMAIN
from .controller import OmadaSiteController
-PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE]
+PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE, Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py
new file mode 100644
index 000000000000..caaae3465b77
--- /dev/null
+++ b/homeassistant/components/tplink_omada/binary_sensor.py
@@ -0,0 +1,120 @@
+"""Support for TPLink Omada binary sensors."""
+from __future__ import annotations
+
+from collections.abc import Callable, Generator
+
+from attr import dataclass
+from tplink_omada_client.definitions import GatewayPortMode, LinkStatus
+from tplink_omada_client.devices import OmadaDevice, OmadaGateway, OmadaGatewayPort
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .controller import OmadaGatewayCoordinator, OmadaSiteController
+from .entity import OmadaDeviceEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensors."""
+ controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id]
+ omada_client = controller.omada_client
+
+ gateway_coordinator = await controller.get_gateway_coordinator()
+ if not gateway_coordinator:
+ return
+
+ gateway = await omada_client.get_gateway(gateway_coordinator.mac)
+
+ async_add_entities(
+ get_gateway_port_status_sensors(gateway, hass, gateway_coordinator)
+ )
+
+ await gateway_coordinator.async_request_refresh()
+
+
+def get_gateway_port_status_sensors(
+ gateway: OmadaGateway, hass: HomeAssistant, coordinator: OmadaGatewayCoordinator
+) -> Generator[BinarySensorEntity, None, None]:
+ """Generate binary sensors for gateway ports."""
+ for port in gateway.port_status:
+ if port.mode == GatewayPortMode.WAN:
+ yield OmadaGatewayPortBinarySensor(
+ coordinator,
+ gateway,
+ GatewayPortBinarySensorConfig(
+ port_number=port.port_number,
+ id_suffix="wan_link",
+ name_suffix="Internet Link",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ update_func=lambda p: p.wan_connected,
+ ),
+ )
+ if port.mode == GatewayPortMode.LAN:
+ yield OmadaGatewayPortBinarySensor(
+ coordinator,
+ gateway,
+ GatewayPortBinarySensorConfig(
+ port_number=port.port_number,
+ id_suffix="lan_status",
+ name_suffix="LAN Status",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ update_func=lambda p: p.link_status == LinkStatus.LINK_UP,
+ ),
+ )
+
+
+@dataclass
+class GatewayPortBinarySensorConfig:
+ """Config for a binary status derived from a gateway port."""
+
+ port_number: int
+ id_suffix: str
+ name_suffix: str
+ device_class: BinarySensorDeviceClass
+ update_func: Callable[[OmadaGatewayPort], bool]
+
+
+class OmadaGatewayPortBinarySensor(OmadaDeviceEntity[OmadaGateway], BinarySensorEntity):
+ """Binary status of a property on an internet gateway."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: OmadaGatewayCoordinator,
+ device: OmadaDevice,
+ config: GatewayPortBinarySensorConfig,
+ ) -> None:
+ """Initialize the gateway port binary sensor."""
+ super().__init__(coordinator, device)
+ self._config = config
+ self._attr_unique_id = f"{device.mac}_{config.port_number}_{config.id_suffix}"
+ self._attr_device_class = config.device_class
+
+ self._attr_name = f"Port {config.port_number} {config.name_suffix}"
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ gateway = self.coordinator.data[self.device.mac]
+
+ port = next(
+ p for p in gateway.port_status if p.port_number == self._config.port_number
+ )
+ if port:
+ self._attr_is_on = self._config.update_func(port)
+ self._attr_available = True
+ else:
+ self._attr_available = False
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py
index 508a8b914da9..194f18ae9bfc 100644
--- a/homeassistant/components/tplink_omada/controller.py
+++ b/homeassistant/components/tplink_omada/controller.py
@@ -1,6 +1,10 @@
"""Controller for sharing Omada API coordinators between platforms."""
-from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails
+from tplink_omada_client.devices import (
+ OmadaGateway,
+ OmadaSwitch,
+ OmadaSwitchPortDetails,
+)
from tplink_omada_client.omadasiteclient import OmadaSiteClient
from homeassistant.core import HomeAssistant
@@ -8,6 +12,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import OmadaCoordinator
POLL_SWITCH_PORT = 300
+POLL_GATEWAY = 300
class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]):
@@ -31,9 +36,31 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]):
return {p.port_id: p for p in ports}
+class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]):
+ """Coordinator for getting details about the site's gateway."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ omada_client: OmadaSiteClient,
+ mac: str,
+ ) -> None:
+ """Initialize my coordinator."""
+ super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY)
+ self.mac = mac
+
+ async def poll_update(self) -> dict[str, OmadaGateway]:
+ """Poll a the gateway's current state."""
+ gateway = await self.omada_client.get_gateway(self.mac)
+ return {self.mac: gateway}
+
+
class OmadaSiteController:
"""Controller for the Omada SDN site."""
+ _gateway_coordinator: OmadaGatewayCoordinator | None = None
+ _initialized_gateway_coordinator = False
+
def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None:
"""Create the controller."""
self._hass = hass
@@ -56,3 +83,18 @@ class OmadaSiteController:
)
return self._switch_port_coordinators[switch.mac]
+
+ async def get_gateway_coordinator(self) -> OmadaGatewayCoordinator | None:
+ """Get coordinator for site's gateway, or None if there is no gateway."""
+ if not self._initialized_gateway_coordinator:
+ self._initialized_gateway_coordinator = True
+ devices = await self._omada_client.get_devices()
+ gateway = next((d for d in devices if d.type == "gateway"), None)
+ if not gateway:
+ return None
+
+ self._gateway_coordinator = OmadaGatewayCoordinator(
+ self._hass, self._omada_client, gateway.mac
+ )
+
+ return self._gateway_coordinator
diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py
index 5d0b188f724a..84619b7a9830 100644
--- a/homeassistant/components/trace/__init__.py
+++ b/homeassistant/components/trace/__init__.py
@@ -37,6 +37,8 @@ TRACE_CONFIG_SCHEMA = {
vol.Optional(CONF_STORED_TRACES, default=DEFAULT_STORED_TRACES): cv.positive_int
}
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
TraceData = dict[str, LimitedSizeDict[str, BaseTrace]]
diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py
index a1f984e55567..2fb6cfb642ad 100644
--- a/homeassistant/components/trafikverket_ferry/config_flow.py
+++ b/homeassistant/components/trafikverket_ferry/config_flow.py
@@ -5,6 +5,7 @@ from collections.abc import Mapping
from typing import Any
from pytrafikverket import TrafikverketFerry
+from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound
import voluptuous as vol
from homeassistant import config_entries
@@ -16,9 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
from .util import create_unique_id
-ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication"
-ERROR_INVALID_ROUTE = "No FerryAnnouncement found"
-
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): selector.TextSelector(
@@ -81,13 +79,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.validate_input(
api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO]
)
- except ValueError as err:
- if str(err) == ERROR_INVALID_AUTH:
- errors["base"] = "invalid_auth"
- elif str(err) == ERROR_INVALID_ROUTE:
- errors["base"] = "invalid_route"
- else:
- errors["base"] = "cannot_connect"
+ except InvalidAuthentication:
+ errors["base"] = "invalid_auth"
+ except NoFerryFound:
+ errors["base"] = "invalid_route"
+ except Exception: # pylint: disable=broad-exception-caught
+ errors["base"] = "cannot_connect"
else:
self.hass.config_entries.async_update_entry(
self.entry,
@@ -126,13 +123,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
await self.validate_input(api_key, ferry_from, ferry_to)
- except ValueError as err:
- if str(err) == ERROR_INVALID_AUTH:
- errors["base"] = "invalid_auth"
- elif str(err) == ERROR_INVALID_ROUTE:
- errors["base"] = "invalid_route"
- else:
- errors["base"] = "cannot_connect"
+ except InvalidAuthentication:
+ errors["base"] = "invalid_auth"
+ except NoFerryFound:
+ errors["base"] = "invalid_route"
+ except Exception: # pylint: disable=broad-exception-caught
+ errors["base"] = "cannot_connect"
else:
if not errors:
unique_id = create_unique_id(
diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py
index 7c2c64d49f0c..32c97b4fe0a5 100644
--- a/homeassistant/components/trafikverket_ferry/coordinator.py
+++ b/homeassistant/components/trafikverket_ferry/coordinator.py
@@ -6,14 +6,16 @@ import logging
from typing import Any
from pytrafikverket import TrafikverketFerry
+from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound
from pytrafikverket.trafikverket_ferry import FerryStop
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
@@ -58,20 +60,22 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator):
)
self._from: str = entry.data[CONF_FROM]
self._to: str = entry.data[CONF_TO]
- self._time: time | None = dt.parse_time(entry.data[CONF_TIME])
+ self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME])
self._weekdays: list[str] = entry.data[CONF_WEEKDAY]
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Trafikverket."""
departure_day = next_departuredate(self._weekdays)
- current_time = dt.now()
+ current_time = dt_util.now()
when = (
datetime.combine(
- departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone)
+ departure_day,
+ self._time,
+ dt_util.get_time_zone(self.hass.config.time_zone),
)
if self._time
- else dt.now()
+ else dt_util.now()
)
if current_time > when:
when = current_time
@@ -82,10 +86,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator):
] = await self._ferry_api.async_get_next_ferry_stops(
self._from, self._to, when, 3
)
- except ValueError as error:
+ except NoFerryFound as error:
raise UpdateFailed(
f"Departure {when} encountered a problem: {error}"
) from error
+ except InvalidAuthentication as error:
+ raise ConfigEntryAuthFailed(error) from error
states = {
"departure_time": routedata[0].departure_time,
diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json
index 2dabb29c4d73..5822566505b7 100644
--- a/homeassistant/components/trafikverket_ferry/manifest.json
+++ b/homeassistant/components/trafikverket_ferry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==0.2.3"]
+ "requirements": ["pytrafikverket==0.3.3"]
}
diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py
index 0bedb7141c6e..8047cf2046d3 100644
--- a/homeassistant/components/trafikverket_train/__init__.py
+++ b/homeassistant/components/trafikverket_train/__init__.py
@@ -2,6 +2,11 @@
from __future__ import annotations
from pytrafikverket import TrafikverketTrain
+from pytrafikverket.exceptions import (
+ InvalidAuthentication,
+ MultipleTrainStationsFound,
+ NoTrainStationFound,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
@@ -21,9 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
to_station = await train_api.async_get_train_station(entry.data[CONF_TO])
from_station = await train_api.async_get_train_station(entry.data[CONF_FROM])
- except ValueError as error:
- if "Invalid authentication" in error.args[0]:
- raise ConfigEntryAuthFailed from error
+ except InvalidAuthentication as error:
+ raise ConfigEntryAuthFailed from error
+ except (NoTrainStationFound, MultipleTrainStationsFound) as error:
raise ConfigEntryNotReady(
f"Problem when trying station {entry.data[CONF_FROM]} to"
f" {entry.data[CONF_TO]}. Error: {error} "
diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py
index c620e264142d..fc23d3b953de 100644
--- a/homeassistant/components/trafikverket_train/config_flow.py
+++ b/homeassistant/components/trafikverket_train/config_flow.py
@@ -5,6 +5,11 @@ from collections.abc import Mapping
from typing import Any
from pytrafikverket import TrafikverketTrain
+from pytrafikverket.exceptions import (
+ InvalidAuthentication,
+ MultipleTrainStationsFound,
+ NoTrainStationFound,
+)
import voluptuous as vol
from homeassistant import config_entries
@@ -12,23 +17,30 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+)
import homeassistant.util.dt as dt_util
from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
from .util import create_unique_id
-ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication"
-ERROR_INVALID_STATION = "Could not find a station with the specified name"
-ERROR_MULTIPLE_STATION = "Found multiple stations with the specified name"
-
DATA_SCHEMA = vol.Schema(
{
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_FROM): cv.string,
- vol.Required(CONF_TO): cv.string,
- vol.Optional(CONF_TIME): cv.string,
- vol.Required(CONF_WEEKDAY, default=WEEKDAYS): cv.multi_select(
- {day: day for day in WEEKDAYS}
+ vol.Required(CONF_API_KEY): TextSelector(),
+ vol.Required(CONF_FROM): TextSelector(),
+ vol.Required(CONF_TO): TextSelector(),
+ vol.Optional(CONF_TIME): TextSelector(),
+ vol.Required(CONF_WEEKDAY, default=WEEKDAYS): SelectSelector(
+ SelectSelectorConfig(
+ options=WEEKDAYS,
+ multiple=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_WEEKDAY,
+ )
),
}
)
@@ -75,15 +87,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.validate_input(
api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO]
)
- except ValueError as err:
- if str(err) == ERROR_INVALID_AUTH:
- errors["base"] = "invalid_auth"
- elif str(err) == ERROR_INVALID_STATION:
- errors["base"] = "invalid_station"
- elif str(err) == ERROR_MULTIPLE_STATION:
- errors["base"] = "more_stations"
- else:
- errors["base"] = "cannot_connect"
+ except InvalidAuthentication:
+ errors["base"] = "invalid_auth"
+ except NoTrainStationFound:
+ errors["base"] = "invalid_station"
+ except MultipleTrainStationsFound:
+ errors["base"] = "more_stations"
+ except Exception: # pylint: disable=broad-exception-caught
+ errors["base"] = "cannot_connect"
else:
self.hass.config_entries.async_update_entry(
self.entry,
@@ -120,15 +131,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
await self.validate_input(api_key, train_from, train_to)
- except ValueError as err:
- if str(err) == ERROR_INVALID_AUTH:
- errors["base"] = "invalid_auth"
- elif str(err) == ERROR_INVALID_STATION:
- errors["base"] = "invalid_station"
- elif str(err) == ERROR_MULTIPLE_STATION:
- errors["base"] = "more_stations"
- else:
- errors["base"] = "cannot_connect"
+ except InvalidAuthentication:
+ errors["base"] = "invalid_auth"
+ except NoTrainStationFound:
+ errors["base"] = "invalid_station"
+ except MultipleTrainStationsFound:
+ errors["base"] = "more_stations"
+ except Exception: # pylint: disable=broad-exception-caught
+ errors["base"] = "cannot_connect"
else:
if train_time:
if bool(dt_util.parse_time(train_time) is None):
diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json
index bdd3dd9ab4a5..7b8369cec17e 100644
--- a/homeassistant/components/trafikverket_train/manifest.json
+++ b/homeassistant/components/trafikverket_train/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==0.2.3"]
+ "requirements": ["pytrafikverket==0.3.3"]
}
diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py
index 678a0af2294b..4ea6ff48dc1c 100644
--- a/homeassistant/components/trafikverket_train/sensor.py
+++ b/homeassistant/components/trafikverket_train/sensor.py
@@ -3,9 +3,13 @@ from __future__ import annotations
from datetime import date, datetime, time, timedelta
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from pytrafikverket import TrafikverketTrain
+from pytrafikverket.exceptions import (
+ MultipleTrainAnnouncementFound,
+ NoTrainAnnouncementFound,
+)
from pytrafikverket.trafikverket_train import StationInfo, TrainStop
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
@@ -15,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
from .util import create_unique_id
@@ -44,7 +48,7 @@ async def async_setup_entry(
to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO]
from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM]
get_time: str | None = entry.data.get(CONF_TIME)
- train_time = dt.parse_time(get_time) if get_time else None
+ train_time = dt_util.parse_time(get_time) if get_time else None
async_add_entities(
[
@@ -85,7 +89,7 @@ def next_departuredate(departure: list[str]) -> date:
def _to_iso_format(traintime: datetime) -> str:
"""Return isoformatted utc time."""
- return dt.as_utc(traintime).isoformat()
+ return dt_util.as_utc(traintime).isoformat()
class TrainSensor(SensorEntity):
@@ -119,21 +123,26 @@ class TrainSensor(SensorEntity):
name=name,
configuration_url="https://api.trafikinfo.trafikverket.se/",
)
+ if TYPE_CHECKING:
+ assert from_station.name and to_station.name
self._attr_unique_id = create_unique_id(
from_station.name, to_station.name, departuretime, weekday
)
async def async_update(self) -> None:
"""Retrieve latest state."""
- when = dt.now()
+ when = dt_util.now()
_state: TrainStop | None = None
if self._time:
departure_day = next_departuredate(self._weekday)
when = datetime.combine(
- departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone)
+ departure_day,
+ self._time,
+ dt_util.get_time_zone(self.hass.config.time_zone),
)
try:
if self._time:
+ _LOGGER.debug("%s, %s, %s", self._from_station, self._to_station, when)
_state = await self._train_api.async_get_train_stop(
self._from_station, self._to_station, when
)
@@ -141,7 +150,7 @@ class TrainSensor(SensorEntity):
_state = await self._train_api.async_get_next_train_stop(
self._from_station, self._to_station, when
)
- except ValueError as error:
+ except (NoTrainAnnouncementFound, MultipleTrainAnnouncementFound) as error:
_LOGGER.error("Departure %s encountered a problem: %s", when, error)
if not _state:
@@ -153,11 +162,13 @@ class TrainSensor(SensorEntity):
self._attr_available = True
# The original datetime doesn't provide a timezone so therefore attaching it here.
- self._attr_native_value = dt.as_utc(_state.advertised_time_at_location)
+ if TYPE_CHECKING:
+ assert _state.advertised_time_at_location
+ self._attr_native_value = dt_util.as_utc(_state.advertised_time_at_location)
if _state.time_at_location:
- self._attr_native_value = dt.as_utc(_state.time_at_location)
+ self._attr_native_value = dt_util.as_utc(_state.time_at_location)
if _state.estimated_time_at_location:
- self._attr_native_value = dt.as_utc(_state.estimated_time_at_location)
+ self._attr_native_value = dt_util.as_utc(_state.estimated_time_at_location)
self._update_attributes(_state)
@@ -165,7 +176,7 @@ class TrainSensor(SensorEntity):
"""Return extra state attributes."""
attributes: dict[str, Any] = {
- ATTR_DEPARTURE_STATE: state.get_state().name,
+ ATTR_DEPARTURE_STATE: state.get_state().value,
ATTR_CANCELED: state.canceled,
ATTR_DELAY_TIME: None,
ATTR_PLANNED_TIME: None,
diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json
index 6f6ed44f7a53..6c67d881153a 100644
--- a/homeassistant/components/trafikverket_train/strings.json
+++ b/homeassistant/components/trafikverket_train/strings.json
@@ -28,5 +28,18 @@
}
}
}
+ },
+ "selector": {
+ "weekday": {
+ "options": {
+ "mon": "Monday",
+ "tue": "Tuesday",
+ "wed": "Wednesday",
+ "thu": "Thursday",
+ "fri": "Friday",
+ "sat": "Saturday",
+ "sun": "Sunday"
+ }
+ }
}
}
diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py
index 345d625c7c1f..f8f862980453 100644
--- a/homeassistant/components/trafikverket_weatherstation/config_flow.py
+++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py
@@ -1,6 +1,11 @@
"""Adds config flow for Trafikverket Weather integration."""
from __future__ import annotations
+from pytrafikverket.exceptions import (
+ InvalidAuthentication,
+ MultipleWeatherStationsFound,
+ NoWeatherStationFound,
+)
from pytrafikverket.trafikverket_weather import TrafikverketWeather
import voluptuous as vol
@@ -20,15 +25,11 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
entry: config_entries.ConfigEntry
- async def validate_input(self, sensor_api: str, station: str) -> str:
+ async def validate_input(self, sensor_api: str, station: str) -> None:
"""Validate input from user input."""
web_session = async_get_clientsession(self.hass)
weather_api = TrafikverketWeather(web_session, sensor_api)
- try:
- await weather_api.async_get_weather(station)
- except ValueError as err:
- return str(err)
- return "connected"
+ await weather_api.async_get_weather(station)
async def async_step_user(
self, user_input: dict[str, str] | None = None
@@ -41,8 +42,17 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
api_key = user_input[CONF_API_KEY]
station = user_input[CONF_STATION]
- validate = await self.validate_input(api_key, station)
- if validate == "connected":
+ try:
+ await self.validate_input(api_key, station)
+ except InvalidAuthentication:
+ errors["base"] = "invalid_auth"
+ except NoWeatherStationFound:
+ errors["base"] = "invalid_station"
+ except MultipleWeatherStationsFound:
+ errors["base"] = "more_stations"
+ except Exception: # pylint: disable=broad-exception-caught
+ errors["base"] = "cannot_connect"
+ else:
return self.async_create_entry(
title=name,
data={
@@ -50,14 +60,6 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_STATION: station,
},
)
- if validate == "Source: Security, message: Invalid authentication":
- errors["base"] = "invalid_auth"
- elif validate == "Could not find a weather station with the specified name":
- errors["base"] = "invalid_station"
- elif validate == "Found multiple weather stations with the specified name":
- errors["base"] = "more_stations"
- else:
- errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py
index 990dcc0bc0bb..40c551089d27 100644
--- a/homeassistant/components/trafikverket_weatherstation/coordinator.py
+++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py
@@ -4,11 +4,17 @@ from __future__ import annotations
from datetime import timedelta
import logging
+from pytrafikverket.exceptions import (
+ InvalidAuthentication,
+ MultipleWeatherStationsFound,
+ NoWeatherStationFound,
+)
from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -38,6 +44,8 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]):
"""Fetch data from Trafikverket."""
try:
weatherdata = await self._weather_api.async_get_weather(self._station)
- except ValueError as error:
+ except InvalidAuthentication as error:
+ raise ConfigEntryAuthFailed from error
+ except (NoWeatherStationFound, MultipleWeatherStationsFound) as error:
raise UpdateFailed from error
return weatherdata
diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json
index 9b7dad395f90..014637b99f62 100644
--- a/homeassistant/components/trafikverket_weatherstation/manifest.json
+++ b/homeassistant/components/trafikverket_weatherstation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==0.2.3"]
+ "requirements": ["pytrafikverket==0.3.3"]
}
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
index c8eed559f7c4..8523ded1fffc 100644
--- a/homeassistant/components/trafikverket_weatherstation/sensor.py
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
+from typing import TYPE_CHECKING
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -29,6 +30,45 @@ from homeassistant.util.dt import as_utc
from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS
from .coordinator import TVDataUpdateCoordinator
+WIND_DIRECTIONS = [
+ "east",
+ "north_east",
+ "east_south_east",
+ "north",
+ "north_north_east",
+ "north_north_west",
+ "north_west",
+ "south",
+ "south_east",
+ "south_south_west",
+ "south_west",
+ "west",
+]
+PRECIPITATION_AMOUNTNAME = [
+ "error",
+ "mild_rain",
+ "moderate_rain",
+ "heavy_rain",
+ "mild_snow_rain",
+ "moderate_snow_rain",
+ "heavy_snow_rain",
+ "mild_snow",
+ "moderate_snow",
+ "heavy_snow",
+ "other",
+ "none",
+ "error",
+]
+PRECIPITATION_TYPE = [
+ "drizzle",
+ "hail",
+ "none",
+ "rain",
+ "snow",
+ "rain_snow_mixed",
+ "freezing_rain",
+]
+
@dataclass
class TrafikverketRequiredKeysMixin:
@@ -63,10 +103,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
),
TrafikverketSensorEntityDescription(
key="precipitation",
- api_key="precipitationtype",
+ api_key="precipitationtype_translated",
name="Precipitation type",
icon="mdi:weather-snowy-rainy",
entity_registry_enabled_default=False,
+ translation_key="precipitation",
+ options=PRECIPITATION_TYPE,
+ device_class=SensorDeviceClass.ENUM,
),
TrafikverketSensorEntityDescription(
key="wind_direction",
@@ -78,9 +121,12 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
),
TrafikverketSensorEntityDescription(
key="wind_direction_text",
- api_key="winddirectiontext",
+ api_key="winddirectiontext_translated",
name="Wind direction text",
icon="mdi:flag-triangle",
+ translation_key="wind_direction_text",
+ options=WIND_DIRECTIONS,
+ device_class=SensorDeviceClass.ENUM,
),
TrafikverketSensorEntityDescription(
key="wind_speed",
@@ -120,10 +166,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
),
TrafikverketSensorEntityDescription(
key="precipitation_amountname",
- api_key="precipitation_amountname",
+ api_key="precipitation_amountname_translated",
name="Precipitation name",
icon="mdi:weather-pouring",
entity_registry_enabled_default=False,
+ translation_key="precipitation_amountname",
+ options=PRECIPITATION_AMOUNTNAME,
+ device_class=SensorDeviceClass.ENUM,
),
TrafikverketSensorEntityDescription(
key="measure_time",
@@ -190,7 +239,9 @@ class TrafikverketWeatherStation(
def native_value(self) -> StateType | datetime:
"""Return state of sensor."""
if self.entity_description.api_key == "measure_time":
- return _to_datetime(self.coordinator.data.measure_time)
+ if TYPE_CHECKING:
+ assert self.coordinator.data.measure_time
+ return self.coordinator.data.measure_time
state: StateType = getattr(
self.coordinator.data, self.entity_description.api_key
@@ -204,4 +255,6 @@ class TrafikverketWeatherStation(
@property
def available(self) -> bool:
"""Return if entity is available."""
+ if TYPE_CHECKING:
+ assert self.coordinator.data.active
return self.coordinator.data.active and super().available
diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json
index 1ac4bbed01ea..3680fae6d8c6 100644
--- a/homeassistant/components/trafikverket_weatherstation/strings.json
+++ b/homeassistant/components/trafikverket_weatherstation/strings.json
@@ -17,5 +17,53 @@
}
}
}
+ },
+ "entity": {
+ "sensor": {
+ "wind_direction_text": {
+ "state": {
+ "east": "East",
+ "north_east": "North east",
+ "east_south_east": "East-south east",
+ "north": "North",
+ "north_north_east": "North-north east",
+ "north_north_west": "North-north west",
+ "north_west": "North west",
+ "south": "South",
+ "south_east": "South east",
+ "south_south_west": "South-south west",
+ "south_west": "South west",
+ "west": "West"
+ }
+ },
+ "precipitation_amountname": {
+ "state": {
+ "error": "Error",
+ "mild_rain": "Mild rain",
+ "moderate_rain": "Moderate rain",
+ "heavy_rain": "Heavy rain",
+ "mild_snow_rain": "Mild rain and snow mixed",
+ "moderate_snow_rain": "Moderate rain and snow mixed",
+ "heavy_snow_rain": "Heavy rain and snow mixed",
+ "mild_snow": "Mild snow",
+ "moderate_snow": "Moderate snow",
+ "heavy_snow": "Heavy snow",
+ "other": "Other",
+ "none": "None",
+ "unknown": "Unknown"
+ }
+ },
+ "precipitation": {
+ "state": {
+ "drizzle": "Drizzle",
+ "hail": "Hail",
+ "none": "None",
+ "rain": "Rain",
+ "snow": "Snow",
+ "rain_snow_mixed": "Rain and snow mixed",
+ "freezing_rain": "Freezing rain"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index a90a69edcdba..254abbd0d637 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -206,6 +206,7 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up TTS."""
websocket_api.async_register_command(hass, websocket_list_engines)
+ websocket_api.async_register_command(hass, websocket_get_engine)
websocket_api.async_register_command(hass, websocket_list_engine_voices)
# Legacy config options
@@ -363,7 +364,7 @@ class TextToSpeechEntity(RestoreEntity):
@final
async def internal_async_get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Process an audio stream to TTS service.
@@ -376,13 +377,13 @@ class TextToSpeechEntity(RestoreEntity):
)
def get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
raise NotImplementedError()
async def async_get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine.
@@ -477,9 +478,9 @@ class SpeechManager:
def process_options(
self,
engine_instance: TextToSpeechEntity | Provider,
- language: str | None = None,
- options: dict | None = None,
- ) -> tuple[str, dict | None]:
+ language: str | None,
+ options: dict | None,
+ ) -> tuple[str, dict[str, Any]]:
"""Validate and process options."""
# Languages
language = language or engine_instance.default_language
@@ -490,23 +491,18 @@ class SpeechManager:
):
raise HomeAssistantError(f"Language '{language}' not supported")
- # Options
- if (default_options := engine_instance.default_options) and options:
- merged_options = dict(default_options)
- merged_options.update(options)
- options = merged_options
- if not options:
- options = None if default_options is None else dict(default_options)
+ # Update default options with provided options
+ merged_options = dict(engine_instance.default_options or {})
+ merged_options.update(options or {})
- if options is not None:
- supported_options = engine_instance.supported_options or []
- invalid_opts = [
- opt_name for opt_name in options if opt_name not in supported_options
- ]
- if invalid_opts:
- raise HomeAssistantError(f"Invalid options found: {invalid_opts}")
+ supported_options = engine_instance.supported_options or []
+ invalid_opts = [
+ opt_name for opt_name in merged_options if opt_name not in supported_options
+ ]
+ if invalid_opts:
+ raise HomeAssistantError(f"Invalid options found: {invalid_opts}")
- return language, options
+ return language, merged_options
async def async_get_url_path(
self,
@@ -601,7 +597,7 @@ class SpeechManager:
message: str,
cache: bool,
language: str,
- options: dict | None,
+ options: dict[str, Any],
) -> str:
"""Receive TTS, store for view in cache and return filename.
@@ -969,6 +965,47 @@ def websocket_list_engines(
)
+@websocket_api.websocket_command(
+ {
+ "type": "tts/engine/get",
+ vol.Required("engine_id"): str,
+ }
+)
+@callback
+def websocket_get_engine(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
+) -> None:
+ """Get text to speech engine info."""
+ component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN]
+ manager: SpeechManager = hass.data[DATA_TTS_MANAGER]
+
+ engine_id = msg["engine_id"]
+ provider_info: dict[str, Any]
+
+ provider: TextToSpeechEntity | Provider | None = next(
+ (entity for entity in component.entities if entity.entity_id == engine_id), None
+ )
+ if not provider:
+ provider = manager.providers.get(engine_id)
+
+ if not provider:
+ connection.send_error(
+ msg["id"],
+ websocket_api.const.ERR_NOT_FOUND,
+ f"tts engine {engine_id} not found",
+ )
+ return
+
+ provider_info = {
+ "engine_id": engine_id,
+ "supported_languages": provider.supported_languages,
+ }
+
+ connection.send_message(
+ websocket_api.result_message(msg["id"], {"provider": provider_info})
+ )
+
+
@websocket_api.websocket_command(
{
"type": "tts/engine/voices",
diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py
index 138c0bf84c99..619c93746220 100644
--- a/homeassistant/components/tts/legacy.py
+++ b/homeassistant/components/tts/legacy.py
@@ -112,7 +112,7 @@ SCHEMA_SERVICE_SAY = vol.Schema(
async def async_setup_legacy(
hass: HomeAssistant, config: ConfigType
) -> list[Coroutine[Any, Any, None]]:
- """Set up legacy text to speech providers."""
+ """Set up legacy text-to-speech providers."""
tts: SpeechManager = hass.data[DATA_TTS_MANAGER]
# Load service descriptions from tts/services.yaml
@@ -132,7 +132,7 @@ async def async_setup_legacy(
platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type)
if platform is None:
- _LOGGER.error("Unknown text to speech platform specified")
+ _LOGGER.error("Unknown text-to-speech platform specified")
return
try:
@@ -240,13 +240,13 @@ class Provider:
return None
def get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from provider."""
raise NotImplementedError()
async def async_get_tts_audio(
- self, message: str, language: str, options: dict[str, Any] | None = None
+ self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from provider.
diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json
index 741edbc4cefb..249e427c5913 100644
--- a/homeassistant/components/tts/manifest.json
+++ b/homeassistant/components/tts/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "tts",
- "name": "Text-to-Speech (TTS)",
+ "name": "Text-to-speech (TTS)",
"after_dependencies": ["media_player"],
"codeowners": ["@home-assistant/core", "@pvizeli"],
"dependencies": ["http"],
diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py
index 34dc3822e93c..9fc0d40dae08 100644
--- a/homeassistant/components/tts/media_source.py
+++ b/homeassistant/components/tts/media_source.py
@@ -104,7 +104,7 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions:
class TTSMediaSource(MediaSource):
"""Provide text-to-speech providers as media sources."""
- name: str = "Text to Speech"
+ name: str = "Text-to-speech"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize TTSMediaSource."""
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index 959a1834f8da..44b3494ca773 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -171,6 +171,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # Air Purifier
+ # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
+ "kj": (
+ TuyaLightEntityDescription(
+ key=DPCode.LIGHT,
+ name="Backlight",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
# Air conditioner
# https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
"kt": (
diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py
new file mode 100644
index 000000000000..6626889a8097
--- /dev/null
+++ b/homeassistant/components/twitch/const.py
@@ -0,0 +1,10 @@
+"""Const for Twitch."""
+import logging
+
+from twitchAPI.twitch import AuthScope
+
+LOGGER = logging.getLogger(__package__)
+
+CONF_CHANNELS = "channels"
+
+OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS]
diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json
index b954db7270be..c11be26c45a9 100644
--- a/homeassistant/components/twitch/manifest.json
+++ b/homeassistant/components/twitch/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/twitch",
"iot_class": "cloud_polling",
"loggers": ["twitch"],
- "requirements": ["twitchAPI==2.5.2"]
+ "requirements": ["twitchAPI==3.10.0"]
}
diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py
index 63746aae46fb..3211ca1952bc 100644
--- a/homeassistant/components/twitch/sensor.py
+++ b/homeassistant/components/twitch/sensor.py
@@ -1,15 +1,16 @@
"""Support for the Twitch stream status."""
from __future__ import annotations
-import logging
-
+from twitchAPI.helper import first
from twitchAPI.twitch import (
- AuthScope,
AuthType,
InvalidTokenException,
MissingScopeException,
Twitch,
+ TwitchAPIException,
TwitchAuthorizationException,
+ TwitchResourceNotFound,
+ TwitchUser,
)
import voluptuous as vol
@@ -20,26 +21,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_GAME = "game"
-ATTR_TITLE = "title"
-ATTR_SUBSCRIPTION = "subscribed"
-ATTR_SUBSCRIPTION_SINCE = "subscribed_since"
-ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted"
-ATTR_FOLLOW = "following"
-ATTR_FOLLOW_SINCE = "following_since"
-ATTR_FOLLOWING = "followers"
-ATTR_VIEWS = "views"
-
-CONF_CHANNELS = "channels"
-
-ICON = "mdi:twitch"
-
-STATE_OFFLINE = "offline"
-STATE_STREAMING = "streaming"
-
-OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS]
+from .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@@ -51,10 +33,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def setup_platform(
+ATTR_GAME = "game"
+ATTR_TITLE = "title"
+ATTR_SUBSCRIPTION = "subscribed"
+ATTR_SUBSCRIPTION_SINCE = "subscribed_since"
+ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted"
+ATTR_FOLLOW = "following"
+ATTR_FOLLOW_SINCE = "following_since"
+ATTR_FOLLOWING = "followers"
+ATTR_VIEWS = "views"
+
+ICON = "mdi:twitch"
+
+STATE_OFFLINE = "offline"
+STATE_STREAMING = "streaming"
+
+
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
- add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Twitch platform."""
@@ -64,93 +62,67 @@ def setup_platform(
oauth_token = config.get(CONF_TOKEN)
try:
- client = Twitch(
+ client = await Twitch(
app_id=client_id,
app_secret=client_secret,
target_app_auth_scope=OAUTH_SCOPES,
)
client.auto_refresh_auth = False
except TwitchAuthorizationException:
- _LOGGER.error("Invalid client ID or client secret")
+ LOGGER.error("Invalid client ID or client secret")
return
if oauth_token:
try:
- client.set_user_authentication(
+ await client.set_user_authentication(
token=oauth_token, scope=OAUTH_SCOPES, validate=True
)
except MissingScopeException:
- _LOGGER.error("OAuth token is missing required scope")
+ LOGGER.error("OAuth token is missing required scope")
return
except InvalidTokenException:
- _LOGGER.error("OAuth token is invalid")
+ LOGGER.error("OAuth token is invalid")
return
- channels = client.get_users(logins=channels)
+ twitch_users: list[TwitchUser] = []
+ async for channel in client.get_users(logins=channels):
+ twitch_users.append(channel)
- add_entities(
- [TwitchSensor(channel, client) for channel in channels["data"]],
+ async_add_entities(
+ [TwitchSensor(channel, client) for channel in twitch_users],
True,
)
class TwitchSensor(SensorEntity):
- """Representation of an Twitch channel."""
+ """Representation of a Twitch channel."""
_attr_icon = ICON
- def __init__(self, channel: dict[str, str], client: Twitch) -> None:
+ def __init__(self, channel: TwitchUser, client: Twitch) -> None:
"""Initialize the sensor."""
self._client = client
+ self._channel = channel
self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES)
- self._attr_name = channel["display_name"]
- self._attr_unique_id = channel["id"]
+ self._attr_name = channel.display_name
+ self._attr_unique_id = channel.id
- def update(self) -> None:
+ async def async_update(self) -> None:
"""Update device state."""
- followers = self._client.get_users_follows(to_id=self.unique_id)["total"]
- channel = self._client.get_users(user_ids=[self.unique_id])["data"][0]
+ followers = (await self._client.get_users_follows(to_id=self._channel.id)).total
self._attr_extra_state_attributes = {
ATTR_FOLLOWING: followers,
- ATTR_VIEWS: channel["view_count"],
+ ATTR_VIEWS: self._channel.view_count,
}
if self._enable_user_auth:
- user = self._client.get_users()["data"][0]["id"]
-
- subs = self._client.check_user_subscription(
- user_id=user, broadcaster_id=self.unique_id
- )
- if "data" in subs:
- self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True
- self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = subs[
- "data"
- ][0]["is_gift"]
- elif "status" in subs and subs["status"] == 404:
- self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False
- elif "error" in subs:
- _LOGGER.error(
- "Error response on check_user_subscription: %s", subs["error"]
- )
- return
- else:
- _LOGGER.error("Unknown error response on check_user_subscription")
- return
-
- follows = self._client.get_users_follows(
- from_id=user, to_id=self.unique_id
- )["data"]
- self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0
- if len(follows):
- self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[0][
- "followed_at"
- ]
-
- if streams := self._client.get_streams(user_id=[self.unique_id])["data"]:
- stream = streams[0]
+ await self._async_add_user_attributes()
+ if stream := (
+ await first(self._client.get_streams(user_id=[self._channel.id], first=1))
+ ):
self._attr_native_value = STATE_STREAMING
- self._attr_extra_state_attributes[ATTR_GAME] = stream["game_name"]
- self._attr_extra_state_attributes[ATTR_TITLE] = stream["title"]
- self._attr_entity_picture = stream["thumbnail_url"]
+ self._attr_extra_state_attributes[ATTR_GAME] = stream.game_name
+ self._attr_extra_state_attributes[ATTR_TITLE] = stream.title
+ self._attr_entity_picture = stream.thumbnail_url
if self._attr_entity_picture is not None:
self._attr_entity_picture = self._attr_entity_picture.format(
height=24,
@@ -160,4 +132,30 @@ class TwitchSensor(SensorEntity):
self._attr_native_value = STATE_OFFLINE
self._attr_extra_state_attributes[ATTR_GAME] = None
self._attr_extra_state_attributes[ATTR_TITLE] = None
- self._attr_entity_picture = channel["profile_image_url"]
+ self._attr_entity_picture = self._channel.profile_image_url
+
+ async def _async_add_user_attributes(self) -> None:
+ if not (user := await first(self._client.get_users())):
+ return
+ self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False
+ try:
+ sub = await self._client.check_user_subscription(
+ user_id=user.id, broadcaster_id=self._channel.id
+ )
+ self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True
+ self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift
+ except TwitchResourceNotFound:
+ LOGGER.debug("User is not subscribed")
+ except TwitchAPIException as exc:
+ LOGGER.error("Error response on check_user_subscription: %s", exc)
+
+ follows = (
+ await self._client.get_users_follows(
+ from_id=user.id, to_id=self._channel.id
+ )
+ ).data
+ self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0
+ if len(follows):
+ self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[
+ 0
+ ].followed_at
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index f43e30309163..f48191e471a1 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
- "requirements": ["aiounifi==47"],
+ "requirements": ["aiounifi==48"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py
index 7aa7c6d5cf14..fe4399c4c6d3 100644
--- a/homeassistant/components/unifiprotect/binary_sensor.py
+++ b/homeassistant/components/unifiprotect/binary_sensor.py
@@ -14,8 +14,6 @@ from pyunifiprotect.data import (
ProtectAdoptableDeviceModel,
ProtectModelWithId,
Sensor,
- SmartDetectAudioType,
- SmartDetectObjectType,
)
from pyunifiprotect.data.nvr import UOSDisk
@@ -364,8 +362,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_person",
ufp_enabled="is_person_detection_on",
- ufp_event_obj="last_smart_detect_event",
- ufp_smart_type=SmartDetectObjectType.PERSON,
+ ufp_event_obj="last_person_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_obj_vehicle",
@@ -374,8 +371,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_vehicle",
ufp_enabled="is_vehicle_detection_on",
- ufp_event_obj="last_smart_detect_event",
- ufp_smart_type=SmartDetectObjectType.VEHICLE,
+ ufp_event_obj="last_vehicle_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_obj_face",
@@ -384,8 +380,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_face",
ufp_enabled="is_face_detection_on",
- ufp_event_obj="last_smart_detect_event",
- ufp_smart_type=SmartDetectObjectType.FACE,
+ ufp_event_obj="last_face_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_obj_package",
@@ -394,8 +389,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_package",
ufp_enabled="is_package_detection_on",
- ufp_event_obj="last_smart_detect_event",
- ufp_smart_type=SmartDetectObjectType.PACKAGE,
+ ufp_event_obj="last_package_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_audio_any",
@@ -412,8 +406,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_smoke",
ufp_enabled="is_smoke_detection_on",
- ufp_event_obj="last_smart_audio_detect_event",
- ufp_smart_type=SmartDetectAudioType.SMOKE,
+ ufp_event_obj="last_smoke_detect_event",
),
ProtectBinaryEventEntityDescription(
key="smart_audio_cmonx",
@@ -422,8 +415,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
ufp_value="is_smart_detected",
ufp_required_field="can_detect_smoke",
ufp_enabled="is_smoke_detection_on",
- ufp_event_obj="last_smart_audio_detect_event",
- ufp_smart_type=SmartDetectAudioType.CMONX,
+ ufp_event_obj="last_cmonx_detect_event",
),
)
diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py
index 68d48003ba63..88c500f18fd0 100644
--- a/homeassistant/components/unifiprotect/data.py
+++ b/homeassistant/components/unifiprotect/data.py
@@ -40,6 +40,11 @@ from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type
_LOGGER = logging.getLogger(__name__)
ProtectDeviceType = ProtectAdoptableDeviceModel | NVR
+SMART_EVENTS = {
+ EventType.SMART_DETECT,
+ EventType.SMART_AUDIO_DETECT,
+ EventType.SMART_DETECT_LINE,
+}
@callback
@@ -223,6 +228,25 @@ class ProtectData:
# trigger updates for camera that the event references
elif isinstance(obj, Event):
+ if obj.type in SMART_EVENTS:
+ if obj.camera is not None:
+ if obj.end is None:
+ _LOGGER.debug(
+ "%s (%s): New smart detection started for %s (%s)",
+ obj.camera.name,
+ obj.camera.mac,
+ obj.smart_detect_types,
+ obj.id,
+ )
+ else:
+ _LOGGER.debug(
+ "%s (%s): Smart detection ended for %s (%s)",
+ obj.camera.name,
+ obj.camera.mac,
+ obj.smart_detect_types,
+ obj.id,
+ )
+
if obj.type == EventType.DEVICE_ADOPTED:
if obj.metadata is not None and obj.metadata.device_id is not None:
device = self.api.bootstrap.get_device_from_id(
diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py
index 134b55c4b0dc..e123a4bf1bc6 100644
--- a/homeassistant/components/unifiprotect/entity.py
+++ b/homeassistant/components/unifiprotect/entity.py
@@ -1,7 +1,7 @@
"""Shared Entity definition for UniFi Protect Integration."""
from __future__ import annotations
-from collections.abc import Sequence
+from collections.abc import Callable, Sequence
import logging
from typing import Any
@@ -191,6 +191,9 @@ class ProtectDeviceEntity(Entity):
super().__init__()
self.data: ProtectData = data
self.device = device
+ self._async_get_ufp_enabled: Callable[
+ [ProtectAdoptableDeviceModel], bool
+ ] | None = None
if description is None:
self._attr_unique_id = f"{self.device.mac}"
@@ -200,6 +203,8 @@ class ProtectDeviceEntity(Entity):
self._attr_unique_id = f"{self.device.mac}_{description.key}"
name = description.name or ""
self._attr_name = f"{self.device.display_name} {name.title()}"
+ if isinstance(description, ProtectRequiredKeysMixin):
+ self._async_get_ufp_enabled = description.get_ufp_enabled
self._attr_attribution = DEFAULT_ATTRIBUTION
self._async_set_device_info()
@@ -227,24 +232,20 @@ class ProtectDeviceEntity(Entity):
@callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
"""Update Entity object from Protect device."""
- if self.data.last_update_success:
- assert isinstance(device, ProtectAdoptableDeviceModel)
+ assert isinstance(device, ProtectAdoptableDeviceModel)
+
+ if last_update_success := self.data.last_update_success:
self.device = device
- is_connected = self.data.last_update_success and (
- self.device.state == StateType.CONNECTED
- or (not self.device.is_adopted_by_us and self.device.can_adopt)
- )
- if (
- hasattr(self, "entity_description")
- and self.entity_description is not None
- and hasattr(self.entity_description, "get_ufp_enabled")
- ):
- assert isinstance(self.entity_description, ProtectRequiredKeysMixin)
- is_connected = is_connected and self.entity_description.get_ufp_enabled(
- self.device
+ async_get_ufp_enabled = self._async_get_ufp_enabled
+ self._attr_available = (
+ last_update_success
+ and (
+ device.state == StateType.CONNECTED
+ or (not device.is_adopted_by_us and device.can_adopt)
)
- self._attr_available = is_connected
+ and (not async_get_ufp_enabled or async_get_ufp_enabled(device))
+ )
@callback
def _async_updated_event(self, device: ProtectModelWithId) -> None:
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index fcb30cdba5fb..78e2ee3012c1 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["pyunifiprotect", "unifi_discovery"],
"quality_scale": "platinum",
- "requirements": ["pyunifiprotect==4.9.0", "unifi-discovery==1.1.7"],
+ "requirements": ["pyunifiprotect==4.10.1", "unifi-discovery==1.1.7"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py
index 40280c028679..8c6882316281 100644
--- a/homeassistant/components/unifiprotect/models.py
+++ b/homeassistant/components/unifiprotect/models.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
+from datetime import timedelta
from enum import Enum
import logging
from typing import Any, Generic, TypeVar, cast
@@ -10,6 +11,7 @@ from typing import Any, Generic, TypeVar, cast
from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel
from homeassistant.helpers.entity import EntityDescription
+from homeassistant.util import dt as dt_util
from .utils import get_nested_attr
@@ -67,7 +69,6 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]):
"""Mixin for events."""
ufp_event_obj: str | None = None
- ufp_smart_type: str | None = None
def get_event_obj(self, obj: T) -> Event | None:
"""Return value from UniFi Protect device."""
@@ -79,23 +80,22 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]):
def get_is_on(self, obj: T) -> bool:
"""Return value if event is active."""
- value = bool(self.get_ufp_value(obj))
- if value:
- event = self.get_event_obj(obj)
- value = event is not None
- if not value:
- _LOGGER.debug("%s (%s): missing event", self.name, obj.mac)
+ event = self.get_event_obj(obj)
+ if event is None:
+ return False
- if event is not None and self.ufp_smart_type is not None:
- value = self.ufp_smart_type in event.smart_detect_types
- if not value:
- _LOGGER.debug(
- "%s (%s): %s not in %s",
- self.name,
- obj.mac,
- self.ufp_smart_type,
- event.smart_detect_types,
- )
+ now = dt_util.utcnow()
+ value = now > event.start
+ if value and event.end is not None and now > event.end:
+ value = False
+ # only log if the recent ended recently
+ if event.end + timedelta(seconds=10) < now:
+ _LOGGER.debug(
+ "%s (%s): end ended at %s",
+ self.name,
+ obj.mac,
+ event.end.isoformat(),
+ )
if value:
_LOGGER.debug("%s (%s): value is on", self.name, obj.mac)
diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py
index 783955b34012..dec6f10a57f1 100644
--- a/homeassistant/components/unifiprotect/sensor.py
+++ b/homeassistant/components/unifiprotect/sensor.py
@@ -15,7 +15,6 @@ from pyunifiprotect.data import (
ProtectDeviceModel,
ProtectModelWithId,
Sensor,
- SmartDetectObjectType,
)
from homeassistant.components.sensor import (
@@ -528,10 +527,9 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = (
name="License Plate Detected",
icon="mdi:car",
translation_key="license_plate",
- ufp_smart_type=SmartDetectObjectType.LICENSE_PLATE,
ufp_value="is_smart_detected",
ufp_required_field="can_detect_license_plate",
- ufp_event_obj="last_smart_detect_event",
+ ufp_event_obj="last_license_plate_detect_event",
),
)
@@ -767,8 +765,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity):
EventEntityMixin._async_update_device_from_protect(self, device)
is_on = self.entity_description.get_is_on(device)
is_license_plate = (
- self.entity_description.ufp_smart_type
- == SmartDetectObjectType.LICENSE_PLATE
+ self.entity_description.ufp_event_obj == "last_license_plate_detect_event"
)
if (
not is_on
diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py
index 3152213cce8c..061f6745f32d 100644
--- a/homeassistant/components/unifiprotect/utils.py
+++ b/homeassistant/components/unifiprotect/utils.py
@@ -41,18 +41,16 @@ from .const import (
def get_nested_attr(obj: Any, attr: str) -> Any:
"""Fetch a nested attribute."""
- attrs = attr.split(".")
+ if "." not in attr:
+ value = getattr(obj, attr, None)
+ else:
+ value = obj
+ for key in attr.split("."):
+ if not hasattr(value, key):
+ return None
+ value = getattr(value, key)
- value = obj
- for key in attrs:
- if not hasattr(value, key):
- return None
- value = getattr(value, key)
-
- if isinstance(value, Enum):
- value = value.value
-
- return value
+ return value.value if isinstance(value, Enum) else value
@callback
diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py
index e6868be29b91..fda6c1d561be 100644
--- a/homeassistant/components/upcloud/config_flow.py
+++ b/homeassistant/components/upcloud/config_flow.py
@@ -55,6 +55,9 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user", user_input=user_input, errors=errors
)
+ self._abort_if_unique_id_configured(
+ updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}
+ )
return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input)
@callback
diff --git a/homeassistant/components/upcloud/strings.json b/homeassistant/components/upcloud/strings.json
index 3daf70be5981..fc52916e9b43 100644
--- a/homeassistant/components/upcloud/strings.json
+++ b/homeassistant/components/upcloud/strings.json
@@ -11,6 +11,9 @@
"password": "[%key:common::config_flow::data::password%]"
}
}
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"options": {
diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json
index 776c2c59a3a7..4d730d71721b 100644
--- a/homeassistant/components/update/strings.json
+++ b/homeassistant/components/update/strings.json
@@ -14,5 +14,11 @@
"firmware": {
"name": "firmware"
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py
index c6a18a27b422..dcb4009145f0 100644
--- a/homeassistant/components/usb/__init__.py
+++ b/homeassistant/components/usb/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.core import (
callback as hass_callback,
)
from homeassistant.data_entry_flow import BaseServiceInfo
-from homeassistant.helpers import discovery_flow, system_info
+from homeassistant.helpers import config_validation as cv, discovery_flow, system_info
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import USBMatcher, async_get_usb
@@ -47,6 +47,8 @@ __all__ = [
"UsbServiceInfo",
]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
class USBCallbackMatcher(USBMatcher):
"""Callback matcher for the USB integration."""
diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
index 99aecfc406b5..1c6c1b04231f 100644
--- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py
+++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
@@ -165,7 +165,9 @@ class UsgsEarthquakesFeedEntityManager:
await self.async_update()
# Trigger updates at regular intervals.
- async_track_time_interval(self._hass, update, self._scan_interval)
+ async_track_time_interval(
+ self._hass, update, self._scan_interval, cancel_on_shutdown=True
+ )
_LOGGER.debug("Feed entity manager initialized")
async def async_update(self) -> None:
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 050e801f0566..7ad5afaa503d 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -411,7 +411,7 @@ class UtilityMeterSensor(RestoreSensor):
if (old_state_val := self._validate_state(old_state)) is not None:
return new_state_val - old_state_val
- _LOGGER.warning(
+ _LOGGER.debug(
"%s received an invalid state change coming from %s (%s > %s)",
self.name,
self._sensor_source_id,
diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json
index a27a60bba4f3..e0db3ba4e47d 100644
--- a/homeassistant/components/vacuum/strings.json
+++ b/homeassistant/components/vacuum/strings.json
@@ -28,5 +28,11 @@
"returning": "Returning to dock"
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py
index 579accc80903..6f8d00eb48c2 100644
--- a/homeassistant/components/vallox/__init__.py
+++ b/homeassistant/components/vallox/__init__.py
@@ -17,12 +17,12 @@ from vallox_websocket_api.vallox import (
)
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import DeviceInfo
-from homeassistant.helpers.typing import ConfigType, StateType
+from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -159,22 +159,6 @@ class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]):
"""The DataUpdateCoordinator for Vallox."""
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the integration from configuration.yaml (DEPRECATED)."""
- if DOMAIN not in config:
- return True
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config[DOMAIN],
- )
- )
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the client and boot the platforms."""
host = entry.data[CONF_HOST]
diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py
index b9d29b17689b..cfc5993797d1 100644
--- a/homeassistant/components/vallox/config_flow.py
+++ b/homeassistant/components/vallox/config_flow.py
@@ -40,38 +40,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
- async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
- """Handle import from YAML."""
- # We need to use the name from the YAML configuration to avoid
- # breaking existing entity IDs.
- name = data.get(CONF_NAME, DEFAULT_NAME)
- host = data[CONF_HOST]
-
- self._async_abort_entries_match({CONF_HOST: host})
-
- reason = None
- try:
- await validate_host(self.hass, host)
- except InvalidHost:
- _LOGGER.error("An invalid host is configured for Vallox: %s", host)
- reason = "invalid_host"
- except ValloxApiException:
- _LOGGER.error("Cannot connect to Vallox host %s", host)
- reason = "cannot_connect"
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unexpected exception")
- reason = "unknown"
- else:
- return self.async_create_entry(
- title=name,
- data={
- **data,
- CONF_NAME: name,
- },
- )
-
- return self.async_abort(reason=reason)
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py
index a6158ea21d44..a4f6563798da 100644
--- a/homeassistant/components/vallox/sensor.py
+++ b/homeassistant/components/vallox/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import ValloxDataUpdateCoordinator, ValloxEntity
from .const import (
@@ -108,7 +108,7 @@ class ValloxFilterRemainingSensor(ValloxSensorEntity):
return datetime.combine(
next_filter_change_date,
- time(hour=13, minute=0, second=0, tzinfo=dt.DEFAULT_TIME_ZONE),
+ time(hour=13, minute=0, second=0, tzinfo=dt_util.DEFAULT_TIME_ZONE),
)
diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json
index 4b82bba83cd9..850d4fecb27a 100644
--- a/homeassistant/components/vilfo/manifest.json
+++ b/homeassistant/components/vilfo/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/vilfo",
"iot_class": "local_polling",
"loggers": ["vilfo"],
- "requirements": ["vilfo-api-client==0.3.2"]
+ "requirements": ["vilfo-api-client==0.4.1"]
}
diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py
index 2525393739b1..072e0ee431dd 100644
--- a/homeassistant/components/voicerss/tts.py
+++ b/homeassistant/components/voicerss/tts.py
@@ -187,7 +187,7 @@ class VoiceRSSProvider(Provider):
"""Return list of supported languages."""
return SUPPORT_LANGUAGES
- async def async_get_tts_audio(self, message, language, options=None):
+ async def async_get_tts_audio(self, message, language, options):
"""Load TTS from VoiceRSS."""
websession = async_get_clientsession(self.hass)
form_data = self._form_data.copy()
diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py
index 8b96941e00a0..80e24d6eb838 100644
--- a/homeassistant/components/voip/voip.py
+++ b/homeassistant/components/voip/voip.py
@@ -18,6 +18,7 @@ from homeassistant.components.assist_pipeline import (
Pipeline,
PipelineEvent,
PipelineEventType,
+ PipelineNotFound,
async_get_pipeline,
async_pipeline_from_audio_stream,
select as pipeline_select,
@@ -45,7 +46,11 @@ def make_protocol(
DOMAIN,
voip_device.voip_id,
)
- pipeline = async_get_pipeline(hass, pipeline_id)
+ try:
+ pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id)
+ except PipelineNotFound:
+ pipeline = None
+
if (
(pipeline is None)
or (pipeline.stt_engine is None)
@@ -261,6 +266,8 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
await self._tts_done.wait()
_LOGGER.debug("Pipeline finished")
+ except PipelineNotFound:
+ _LOGGER.warning("Pipeline not found")
except asyncio.TimeoutError:
# Expected after caller hangs up
_LOGGER.debug("Pipeline timeout")
@@ -285,13 +292,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
chunk = await self._audio_queue.get()
while chunk:
+ chunk_buffer.append(chunk)
+
segmenter.process(chunk)
if segmenter.in_command:
+ # Buffer until command starts
return True
- # Buffer until command starts
- chunk_buffer.append(chunk)
-
async with async_timeout.timeout(self.audio_timeout):
chunk = await self._audio_queue.get()
diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py
index aae640381a2d..54809d6cec3c 100644
--- a/homeassistant/components/wake_on_lan/__init__.py
+++ b/homeassistant/components/wake_on_lan/__init__.py
@@ -24,6 +24,8 @@ WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema(
}
)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the wake on LAN component."""
diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json
index d1c75217830b..e5630d5fd297 100644
--- a/homeassistant/components/waqi/manifest.json
+++ b/homeassistant/components/waqi/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/waqi",
"iot_class": "cloud_polling",
"loggers": ["waqiasync"],
- "requirements": ["waqiasync==1.0.0"]
+ "requirements": ["waqiasync==1.1.0"]
}
diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json
index 6344b5a847a2..b07842796673 100644
--- a/homeassistant/components/water_heater/strings.json
+++ b/homeassistant/components/water_heater/strings.json
@@ -18,5 +18,11 @@
"performance": "Performance"
}
}
+ },
+ "issues": {
+ "platform_integration_no_support": {
+ "title": "[%key:common::issues::platform_integration_no_support_title%]",
+ "description": "[%key:common::issues::platform_integration_no_support_description%]"
+ }
}
}
diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json
index 2f9404bf146f..f26fc0065613 100644
--- a/homeassistant/components/watson_tts/manifest.json
+++ b/homeassistant/components/watson_tts/manifest.json
@@ -2,6 +2,7 @@
"domain": "watson_tts",
"name": "IBM Watson TTS",
"codeowners": ["@rutkai"],
+ "disabled": "Dependencies not compatible with the new pip resolver",
"documentation": "https://www.home-assistant.io/integrations/watson_tts",
"iot_class": "cloud_push",
"loggers": ["ibm_cloud_sdk_core", "ibm_watson"],
diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py
index f5d643b1336a..7af6c1ce97bf 100644
--- a/homeassistant/components/watson_tts/tts.py
+++ b/homeassistant/components/watson_tts/tts.py
@@ -1,8 +1,10 @@
"""Support for IBM Watson TTS integration."""
import logging
-from ibm_cloud_sdk_core.authenticators import IAMAuthenticator
-from ibm_watson import TextToSpeechV1
+from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error
+ IAMAuthenticator,
+)
+from ibm_watson import TextToSpeechV1 # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.tts import PLATFORM_SCHEMA, Provider
@@ -178,7 +180,7 @@ class WatsonTTSProvider(Provider):
"""Return a list of supported options."""
return [CONF_VOICE]
- def get_tts_audio(self, message, language=None, options=None):
+ def get_tts_audio(self, message, language, options):
"""Request TTS file from Watson TTS."""
response = self.service.synthesize(
text=message, accept=self.output_format, voice=options[CONF_VOICE]
diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json
index a64f84672242..461f715c8dbd 100644
--- a/homeassistant/components/weather/strings.json
+++ b/homeassistant/components/weather/strings.json
@@ -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%]"
+ }
}
}
diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py
index e58890a1d184..e9e5bdf51e8c 100644
--- a/homeassistant/components/webhook/__init__.py
+++ b/homeassistant/components/webhook/__init__.py
@@ -16,6 +16,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.network import get_url
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -30,6 +31,8 @@ DEFAULT_METHODS = (METH_POST, METH_PUT)
SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT)
URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}"
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@callback
@bind_hass
diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py
index c98ca54d25a5..9c2645aec57c 100644
--- a/homeassistant/components/websocket_api/__init__.py
+++ b/homeassistant/components/websocket_api/__init__.py
@@ -6,6 +6,7 @@ from typing import Final, cast
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -41,6 +42,8 @@ DOMAIN: Final = const.DOMAIN
DEPENDENCIES: Final[tuple[str]] = ("http",)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
@bind_hass
@callback
diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py
index 2b146d944724..bdb087069f83 100644
--- a/homeassistant/components/websocket_api/commands.py
+++ b/homeassistant/components/websocket_api/commands.py
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
-from contextlib import suppress
import datetime as dt
from functools import lru_cache
import json
@@ -34,6 +33,7 @@ from homeassistant.helpers.json import (
JSON_DUMP,
ExtendedJSONEncoder,
find_paths_unserializable_data,
+ json_dumps,
)
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.loader import (
@@ -49,6 +49,9 @@ from homeassistant.util.json import format_unserializable_data
from . import const, decorators, messages
from .connection import ActiveConnection
from .const import ERR_NOT_FOUND
+from .messages import construct_event_message, construct_result_message
+
+ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"
@callback
@@ -106,15 +109,18 @@ def handle_subscribe_events(
raise Unauthorized
if event_type == EVENT_STATE_CHANGED:
+ user = connection.user
@callback
def forward_events(event: Event) -> None:
"""Forward state changed events to websocket."""
- if not connection.user.permissions.check_entity(
- event.data["entity_id"], POLICY_READ
- ):
+ # We have to lookup the permissions again because the user might have
+ # changed since the subscription was created.
+ permissions = user.permissions
+ if not permissions.access_all_entities(
+ POLICY_READ
+ ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ):
return
-
connection.send_message(messages.cached_event_message(msg["id"], event))
else:
@@ -224,13 +230,13 @@ async def handle_call_service(
def _async_get_allowed_states(
hass: HomeAssistant, connection: ActiveConnection
) -> list[State]:
- if connection.user.permissions.access_all_entities("read"):
+ if connection.user.permissions.access_all_entities(POLICY_READ):
return hass.states.async_all()
entity_perm = connection.user.permissions.check_entity
return [
state
for state in hass.states.async_all()
- if entity_perm(state.entity_id, "read")
+ if entity_perm(state.entity_id, POLICY_READ)
]
@@ -242,33 +248,36 @@ def handle_get_states(
"""Handle get states command."""
states = _async_get_allowed_states(hass, connection)
- # JSON serialize here so we can recover if it blows up due to the
- # state machine containing unserializable data. This command is required
- # to succeed for the UI to show.
- response = messages.result_message(msg["id"], states)
try:
- connection.send_message(JSON_DUMP(response))
- return
+ serialized_states = [state.as_dict_json() for state in states]
except (ValueError, TypeError):
- connection.logger.error(
- "Unable to serialize to JSON. Bad data found at %s",
- format_unserializable_data(
- find_paths_unserializable_data(response, dump=JSON_DUMP)
- ),
- )
- del response
+ pass
+ else:
+ _send_handle_get_states_response(connection, msg["id"], serialized_states)
+ return
# If we can't serialize, we'll filter out unserializable states
- serialized = []
+ serialized_states = []
for state in states:
- # Error is already logged above
- with suppress(ValueError, TypeError):
- serialized.append(JSON_DUMP(state))
+ try:
+ serialized_states.append(state.as_dict_json())
+ except (ValueError, TypeError):
+ connection.logger.error(
+ "Unable to serialize to JSON. Bad data found at %s",
+ format_unserializable_data(
+ find_paths_unserializable_data(state, dump=JSON_DUMP)
+ ),
+ )
- # We now have partially serialized states. Craft some JSON.
- response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"]))
- response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized))
- connection.send_message(response2)
+ _send_handle_get_states_response(connection, msg["id"], serialized_states)
+
+
+def _send_handle_get_states_response(
+ connection: ActiveConnection, msg_id: int, serialized_states: list[str]
+) -> None:
+ """Send handle get states response."""
+ joined_states = ",".join(serialized_states)
+ connection.send_message(construct_result_message(msg_id, f"[{joined_states}]"))
@callback
@@ -283,17 +292,21 @@ def handle_subscribe_entities(
) -> None:
"""Handle subscribe entities command."""
entity_ids = set(msg.get("entity_ids", []))
+ user = connection.user
@callback
def forward_entity_changes(event: Event) -> None:
"""Forward entity state changed events to websocket."""
- if not connection.user.permissions.check_entity(
- event.data["entity_id"], POLICY_READ
- ):
+ entity_id = event.data["entity_id"]
+ if entity_ids and entity_id not in entity_ids:
return
- if entity_ids and event.data["entity_id"] not in entity_ids:
+ # We have to lookup the permissions again because the user might have
+ # changed since the subscription was created.
+ permissions = user.permissions
+ if not permissions.access_all_entities(
+ POLICY_READ
+ ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ):
return
-
connection.send_message(messages.cached_state_diff_message(msg["id"], event))
# We must never await between sending the states and listening for
@@ -304,42 +317,60 @@ def handle_subscribe_entities(
EVENT_STATE_CHANGED, forward_entity_changes, run_immediately=True
)
connection.send_result(msg["id"])
- data: dict[str, dict[str, dict]] = {
- messages.ENTITY_EVENT_ADD: {
- state.entity_id: state.as_compressed_state()
- for state in states
- if not entity_ids or state.entity_id in entity_ids
- }
- }
# JSON serialize here so we can recover if it blows up due to the
# state machine containing unserializable data. This command is required
# to succeed for the UI to show.
- response = messages.event_message(msg["id"], data)
try:
- connection.send_message(JSON_DUMP(response))
- return
+ serialized_states = [
+ state.as_compressed_state_json()
+ for state in states
+ if not entity_ids or state.entity_id in entity_ids
+ ]
except (ValueError, TypeError):
- connection.logger.error(
- "Unable to serialize to JSON. Bad data found at %s",
- format_unserializable_data(
- find_paths_unserializable_data(response, dump=JSON_DUMP)
- ),
- )
- del response
+ pass
+ else:
+ _send_handle_entities_init_response(connection, msg["id"], serialized_states)
+ return
- add_entities = data[messages.ENTITY_EVENT_ADD]
- cannot_serialize: list[str] = []
- for entity_id, state_dict in add_entities.items():
+ serialized_states = []
+ for state in states:
try:
- JSON_DUMP(state_dict)
+ serialized_states.append(state.as_compressed_state_json())
except (ValueError, TypeError):
- cannot_serialize.append(entity_id)
+ connection.logger.error(
+ "Unable to serialize to JSON. Bad data found at %s",
+ format_unserializable_data(
+ find_paths_unserializable_data(state, dump=JSON_DUMP)
+ ),
+ )
- for entity_id in cannot_serialize:
- del add_entities[entity_id]
+ _send_handle_entities_init_response(connection, msg["id"], serialized_states)
- connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data)))
+
+def _send_handle_entities_init_response(
+ connection: ActiveConnection, msg_id: int, serialized_states: list[str]
+) -> None:
+ """Send handle entities init response."""
+ joined_states = ",".join(serialized_states)
+ connection.send_message(
+ construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}')
+ )
+
+
+async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str:
+ """Return JSON of descriptions (i.e. user documentation) for all service calls."""
+ descriptions = await async_get_all_descriptions(hass)
+ if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data:
+ cached_descriptions, cached_json_payload = hass.data[
+ ALL_SERVICE_DESCRIPTIONS_JSON_CACHE
+ ]
+ # If the descriptions are the same, return the cached JSON payload
+ if cached_descriptions is descriptions:
+ return cast(str, cached_json_payload)
+ json_payload = json_dumps(descriptions)
+ hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload)
+ return json_payload
@decorators.websocket_command({vol.Required("type"): "get_services"})
@@ -348,8 +379,8 @@ async def handle_get_services(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle get services command."""
- descriptions = await async_get_all_descriptions(hass)
- connection.send_result(msg["id"], descriptions)
+ payload = await _async_get_all_descriptions_json(hass)
+ connection.send_message(construct_result_message(msg["id"], payload))
@callback
@@ -517,13 +548,13 @@ def handle_entity_source(
entity_perm = connection.user.permissions.check_entity
if "entity_id" not in msg:
- if connection.user.permissions.access_all_entities("read"):
+ if connection.user.permissions.access_all_entities(POLICY_READ):
sources = raw_sources
else:
sources = {
entity_id: source
for entity_id, source in raw_sources.items()
- if entity_perm(entity_id, "read")
+ if entity_perm(entity_id, POLICY_READ)
}
connection.send_result(msg["id"], sources)
@@ -532,7 +563,7 @@ def handle_entity_source(
sources = {}
for entity_id in msg["entity_id"]:
- if not entity_perm(entity_id, "read"):
+ if not entity_perm(entity_id, POLICY_READ):
raise Unauthorized(
context=connection.context(msg),
permission=POLICY_READ,
@@ -715,7 +746,7 @@ def handle_supported_features(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle setting supported features."""
- connection.supported_features = msg["features"]
+ connection.set_supported_features(msg["features"])
connection.send_result(msg["id"])
diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py
index f91cc3a827af..1f6fd3029689 100644
--- a/homeassistant/components/websocket_api/connection.py
+++ b/homeassistant/components/websocket_api/connection.py
@@ -48,6 +48,7 @@ class ActiveConnection:
self.refresh_token_id = refresh_token.id
self.subscriptions: dict[Hashable, Callable[[], Any]] = {}
self.last_id = 0
+ self.can_coalesce = False
self.supported_features: dict[str, float] = {}
self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[
const.DOMAIN
@@ -55,6 +56,11 @@ class ActiveConnection:
self.binary_handlers: list[BinaryHandler | None] = []
current_connection.set(self)
+ def set_supported_features(self, features: dict[str, float]) -> None:
+ """Set supported features."""
+ self.supported_features = features
+ self.can_coalesce = const.FEATURE_COALESCE_MESSAGES in features
+
def get_description(self, request: web.Request | None) -> str:
"""Return a description of the connection."""
description = self.user.name or ""
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index 75eccc7aba99..5ca5ea625789 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
+from collections import deque
from collections.abc import Callable
from contextlib import suppress
import datetime as dt
@@ -22,7 +23,6 @@ from .auth import AuthPhase, auth_required_message
from .const import (
CANCELLATION_ERRORS,
DATA_CONNECTIONS,
- FEATURE_COALESCE_MESSAGES,
MAX_PENDING_MSG,
PENDING_MSG_PEAK,
PENDING_MSG_PEAK_TIME,
@@ -71,7 +71,6 @@ class WebSocketHandler:
self.hass = hass
self.request = request
self.wsock = web.WebSocketResponse(heartbeat=55)
- self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG)
self._handle_task: asyncio.Task | None = None
self._writer_task: asyncio.Task | None = None
self._closing: bool = False
@@ -79,6 +78,13 @@ class WebSocketHandler:
self._peak_checker_unsub: Callable[[], None] | None = None
self.connection: ActiveConnection | None = None
+ # The WebSocketHandler has a single consumer and path
+ # to where messages are queued. This allows the implementation
+ # to use a deque and an asyncio.Future to avoid the overhead of
+ # an asyncio.Queue.
+ self._message_queue: deque = deque()
+ self._ready_future: asyncio.Future[None] | None = None
+
@property
def description(self) -> str:
"""Return a description of the connection."""
@@ -88,39 +94,53 @@ class WebSocketHandler:
async def _writer(self) -> None:
"""Write outgoing messages."""
- # Exceptions if Socket disconnected or cancelled by connection handler
- to_write = self._to_write
+ # Variables are set locally to avoid lookups in the loop
+ message_queue = self._message_queue
logger = self._logger
- wsock = self.wsock
+ send_str = self.wsock.send_str
+ loop = self.hass.loop
+ debug = logger.debug
+ # Exceptions if Socket disconnected or cancelled by connection handler
try:
with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
while not self.wsock.closed:
- if (process := await to_write.get()) is None:
+ if (messages_remaining := len(message_queue)) == 0:
+ self._ready_future = loop.create_future()
+ await self._ready_future
+ messages_remaining = len(message_queue)
+
+ # A None message is used to signal the end of the connection
+ if (process := message_queue.popleft()) is None:
return
+
+ messages_remaining -= 1
message = process if isinstance(process, str) else process()
+
if (
- to_write.empty()
+ not messages_remaining
or not self.connection
- or FEATURE_COALESCE_MESSAGES
- not in self.connection.supported_features
+ or not self.connection.can_coalesce
):
- logger.debug("Sending %s", message)
- await wsock.send_str(message)
+ debug("Sending %s", message)
+ await send_str(message)
continue
messages: list[str] = [message]
- while not to_write.empty():
- if (process := to_write.get_nowait()) is None:
+ while messages_remaining:
+ # A None message is used to signal the end of the connection
+ if (process := message_queue.popleft()) is None:
return
messages.append(
process if isinstance(process, str) else process()
)
+ messages_remaining -= 1
- coalesced_messages = "[" + ",".join(messages) + "]"
- logger.debug("Sending %s", coalesced_messages)
- await wsock.send_str(coalesced_messages)
+ joined_messages = ",".join(messages)
+ coalesced_messages = f"[{joined_messages}]"
+ debug("Sending %s", coalesced_messages)
+ await send_str(coalesced_messages)
finally:
- # Clean up the peaker checker when we shut down the writer
+ # Clean up the peak checker when we shut down the writer
self._cancel_peak_checker()
@callback
@@ -146,26 +166,29 @@ class WebSocketHandler:
if isinstance(message, dict):
message = message_to_json(message)
- to_write = self._to_write
-
- try:
- to_write.put_nowait(message)
- except asyncio.QueueFull:
+ message_queue = self._message_queue
+ queue_size_before_add = len(message_queue)
+ if queue_size_before_add >= MAX_PENDING_MSG:
self._logger.error(
(
"%s: Client unable to keep up with pending messages. Reached %s pending"
" messages. The system's load is too high or an integration is"
- " misbehaving. Last message was: %s"
+ " misbehaving; Last message was: %s"
),
self.description,
MAX_PENDING_MSG,
message,
)
self._cancel()
+ return
+
+ message_queue.append(message)
+ if self._ready_future and not self._ready_future.done():
+ self._ready_future.set_result(None)
peak_checker_active = self._peak_checker_unsub is not None
- if to_write.qsize() < PENDING_MSG_PEAK:
+ if queue_size_before_add <= PENDING_MSG_PEAK:
if peak_checker_active:
self._cancel_peak_checker()
return
@@ -180,18 +203,19 @@ class WebSocketHandler:
"""Check that we are no longer above the write peak."""
self._peak_checker_unsub = None
- if self._to_write.qsize() < PENDING_MSG_PEAK:
+ if len(self._message_queue) < PENDING_MSG_PEAK:
return
self._logger.error(
(
"%s: Client unable to keep up with pending messages. Stayed over %s for %s"
" seconds. The system's load is too high or an integration is"
- " misbehaving"
+ " misbehaving; Last message was: %s"
),
self.description,
PENDING_MSG_PEAK,
PENDING_MSG_PEAK_TIME,
+ self._message_queue[-1],
)
self._cancel()
@@ -199,6 +223,7 @@ class WebSocketHandler:
def _cancel(self) -> None:
"""Cancel the connection."""
self._closing = True
+ self._cancel_peak_checker()
if self._handle_task is not None:
self._handle_task.cancel()
if self._writer_task is not None:
@@ -356,14 +381,14 @@ class WebSocketHandler:
self._closing = True
+ self._message_queue.append(None)
+ if self._ready_future and not self._ready_future.done():
+ self._ready_future.set_result(None)
+
try:
- self._to_write.put_nowait(None)
# Make sure all error messages are written before closing
await self._writer_task
await wsock.close()
- except asyncio.QueueFull: # can be raised by put_nowait
- self._writer_task.cancel()
-
finally:
if disconnect_warn is None:
self._logger.debug("Disconnected")
diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py
index ec1ab267a37b..3d85f984e9af 100644
--- a/homeassistant/components/websocket_api/messages.py
+++ b/homeassistant/components/websocket_api/messages.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from functools import lru_cache
import logging
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final, cast
import voluptuous as vol
@@ -18,7 +18,6 @@ from homeassistant.core import Event, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data
from homeassistant.util.json import format_unserializable_data
-from homeassistant.util.yaml.loader import JSON_TYPE
from . import const
@@ -49,6 +48,12 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]:
return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result}
+def construct_result_message(iden: int, payload: str) -> str:
+ """Construct a success result message JSON."""
+ iden_str = str(iden)
+ return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}'
+
+
def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]:
"""Return an error result message."""
return {
@@ -59,7 +64,13 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]:
}
-def event_message(iden: JSON_TYPE | int, event: Any) -> dict[str, Any]:
+def construct_event_message(iden: int, payload: str) -> str:
+ """Construct an event message JSON."""
+ iden_str = str(iden)
+ return f'{{"id":{iden_str},"type":"event","event":{payload}}}'
+
+
+def event_message(iden: int, event: Any) -> dict[str, Any]:
"""Return an event message."""
return {"id": iden, "type": "event", "event": event}
@@ -83,7 +94,7 @@ def _cached_event_message(event: Event) -> str:
The IDEN_TEMPLATE is used which will be replaced
with the actual iden in cached_event_message
"""
- return message_to_json(event_message(IDEN_TEMPLATE, event))
+ return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event})
def cached_state_diff_message(iden: int, event: Event) -> str:
@@ -105,7 +116,9 @@ def _cached_state_diff_message(event: Event) -> str:
The IDEN_TEMPLATE is used which will be replaced
with the actual iden in cached_event_message
"""
- return message_to_json(event_message(IDEN_TEMPLATE, _state_diff_event(event)))
+ return message_to_json(
+ {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)}
+ )
def _state_diff_event(event: Event) -> dict:
@@ -121,14 +134,16 @@ def _state_diff_event(event: Event) -> dict:
"""
if (event_new_state := event.data["new_state"]) is None:
return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]}
- assert isinstance(event_new_state, State)
+ if TYPE_CHECKING:
+ event_new_state = cast(State, event_new_state)
if (event_old_state := event.data["old_state"]) is None:
return {
ENTITY_EVENT_ADD: {
event_new_state.entity_id: event_new_state.as_compressed_state()
}
}
- assert isinstance(event_old_state, State)
+ if TYPE_CHECKING:
+ event_old_state = cast(State, event_old_state)
return _state_diff(event_old_state, event_new_state)
@@ -136,27 +151,28 @@ def _state_diff(
old_state: State, new_state: State
) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]:
"""Create a diff dict that can be used to overlay changes."""
- diff: dict = {STATE_DIFF_ADDITIONS: {}}
- additions = diff[STATE_DIFF_ADDITIONS]
+ additions: dict[str, Any] = {}
+ diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions}
+ new_state_context = new_state.context
+ old_state_context = old_state.context
if old_state.state != new_state.state:
additions[COMPRESSED_STATE_STATE] = new_state.state
if old_state.last_changed != new_state.last_changed:
additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp()
elif old_state.last_updated != new_state.last_updated:
additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp()
- if old_state.context.parent_id != new_state.context.parent_id:
- additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[
- "parent_id"
- ] = new_state.context.parent_id
- if old_state.context.user_id != new_state.context.user_id:
- additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[
- "user_id"
- ] = new_state.context.user_id
- if old_state.context.id != new_state.context.id:
+ if old_state_context.parent_id != new_state_context.parent_id:
+ additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id}
+ if old_state_context.user_id != new_state_context.user_id:
if COMPRESSED_STATE_CONTEXT in additions:
- additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id
+ additions[COMPRESSED_STATE_CONTEXT]["user_id"] = new_state_context.user_id
else:
- additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id
+ additions[COMPRESSED_STATE_CONTEXT] = {"user_id": new_state_context.user_id}
+ if old_state_context.id != new_state_context.id:
+ if COMPRESSED_STATE_CONTEXT in additions:
+ additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state_context.id
+ else:
+ additions[COMPRESSED_STATE_CONTEXT] = new_state_context.id
if (old_attributes := old_state.attributes) != (
new_attributes := new_state.attributes
):
diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py
index 11a46293f29a..b208a30f5ec7 100644
--- a/homeassistant/components/wemo/__init__.py
+++ b/homeassistant/components/wemo/__init__.py
@@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
wemo_dispatcher = WemoDispatcher(entry)
wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf)
- async def async_stop_wemo(event: Event) -> None:
+ async def async_stop_wemo(_: Event | None = None) -> None:
"""Shutdown Wemo subscriptions and subscription thread on exit."""
_LOGGER.debug("Shutting down WeMo event subscriptions")
await hass.async_add_executor_job(registry.stop)
@@ -123,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo)
)
+ entry.async_on_unload(async_stop_wemo)
# Need to do this at least once in case statistics are defined and discovery is disabled
await wemo_discovery.discover_statics()
@@ -133,6 +134,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a wemo config entry."""
+ # This makes sure that `entry.async_on_unload` routines run correctly on unload
+ return True
+
+
class WemoDispatcher:
"""Dispatch WeMo devices to the correct platform."""
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
index 81065cf8108d..1d2c2c9252dc 100644
--- a/homeassistant/components/wemo/fan.py
+++ b/homeassistant/components/wemo/fan.py
@@ -86,6 +86,7 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity):
_attr_supported_features = FanEntityFeature.SET_SPEED
wemo: Humidifier
+ _last_fan_on_mode: FanMode
def __init__(self, coordinator: DeviceCoordinator) -> None:
"""Initialize the WeMo switch."""
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index 9c26a0319f7b..5b83c35cca42 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -290,12 +290,15 @@ class WasherDryerTimeClass(RestoreSensor):
if machine_state is MachineState.RunningMainCycle:
self._running = True
+
new_timestamp = now + timedelta(
seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining"))
)
- if isinstance(self._attr_native_value, datetime) and abs(
- new_timestamp - self._attr_native_value
- ) > timedelta(seconds=60):
+ if (
+ isinstance(self._attr_native_value, datetime)
+ and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60)
+ or self._attr_native_value is None
+ ):
self._attr_native_value = new_timestamp
self._async_write_ha_state()
diff --git a/homeassistant/components/whisper/__init__.py b/homeassistant/components/whisper/__init__.py
new file mode 100644
index 000000000000..572f91f68d37
--- /dev/null
+++ b/homeassistant/components/whisper/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Whisper."""
diff --git a/homeassistant/components/whisper/manifest.json b/homeassistant/components/whisper/manifest.json
new file mode 100644
index 000000000000..1bc0f12e07c3
--- /dev/null
+++ b/homeassistant/components/whisper/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "whisper",
+ "name": "Whisper",
+ "integration_type": "virtual",
+ "supported_by": "wyoming"
+}
diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py
index 94aaa9aa5a6a..682efde88816 100644
--- a/homeassistant/components/withings/__init__.py
+++ b/homeassistant/components/withings/__init__.py
@@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
# Start subscription check in the background, outside this component's setup.
- async_call_later(hass, 1, async_call_later_callback)
+ entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py
index a691ffe8f9e5..1b173e3a3778 100644
--- a/homeassistant/components/withings/common.py
+++ b/homeassistant/components/withings/common.py
@@ -41,7 +41,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import const
from .const import Measurement
@@ -411,7 +411,7 @@ class DataManager:
async def async_get_measures(self) -> dict[Measurement, Any]:
"""Get the measures data."""
_LOGGER.debug("Updating withings measures")
- now = dt.utcnow()
+ now = dt_util.utcnow()
startdate = now - datetime.timedelta(days=7)
response = await self._hass.async_add_executor_job(
@@ -439,7 +439,7 @@ class DataManager:
async def async_get_sleep_summary(self) -> dict[Measurement, Any]:
"""Get the sleep summary data."""
_LOGGER.debug("Updating withing sleep summary")
- now = dt.utcnow()
+ now = dt_util.utcnow()
yesterday = now - datetime.timedelta(days=1)
yesterday_noon = datetime.datetime(
yesterday.year,
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
index 9c2e453c03d3..a68a8215cb4a 100644
--- a/homeassistant/components/workday/binary_sensor.py
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
ALLOWED_DAYS,
@@ -120,7 +120,7 @@ async def async_setup_entry(
sensor_name: str = entry.options[CONF_NAME]
workdays: list[str] = entry.options[CONF_WORKDAYS]
- year: int = (dt.now() + timedelta(days=days_offset)).year
+ year: int = (dt_util.now() + timedelta(days=days_offset)).year
obj_holidays: HolidayBase = getattr(holidays, country)(years=year)
if province:
@@ -140,7 +140,7 @@ async def async_setup_entry(
for remove_holiday in remove_holidays:
try:
# is this formatted as a date?
- if dt.parse_date(remove_holiday):
+ if dt_util.parse_date(remove_holiday):
# remove holiday by date
removed = obj_holidays.pop(remove_holiday)
LOGGER.debug("Removed %s", remove_holiday)
@@ -231,7 +231,7 @@ class IsWorkdaySensor(BinarySensorEntity):
self._attr_is_on = False
# Get ISO day of the week (1 = Monday, 7 = Sunday)
- adjusted_date = dt.now() + timedelta(days=self._days_offset)
+ adjusted_date = dt_util.now() + timedelta(days=self._days_offset)
day = adjusted_date.isoweekday() - 1
day_of_week = ALLOWED_DAYS[day]
diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py
index be11b0b034d8..7f0c7906621e 100644
--- a/homeassistant/components/workday/config_flow.py
+++ b/homeassistant/components/workday/config_flow.py
@@ -3,8 +3,7 @@ from __future__ import annotations
from typing import Any
-import holidays
-from holidays import HolidayBase
+from holidays import country_holidays, list_supported_countries
import voluptuous as vol
from homeassistant.config_entries import (
@@ -25,7 +24,7 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
TextSelector,
)
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .const import (
ALLOWED_DAYS,
@@ -41,6 +40,7 @@ from .const import (
DEFAULT_OFFSET,
DEFAULT_WORKDAYS,
DOMAIN,
+ LOGGER,
)
NONE_SENTINEL = "none"
@@ -48,15 +48,14 @@ NONE_SENTINEL = "none"
def add_province_to_schema(
schema: vol.Schema,
- options: dict[str, Any],
+ country: str,
) -> vol.Schema:
"""Update schema with province from country."""
- year: int = dt.now().year
- obj_holidays: HolidayBase = getattr(holidays, options[CONF_COUNTRY])(years=year)
- if not obj_holidays.subdivisions:
+ all_countries = list_supported_countries()
+ if not all_countries[country]:
return schema
- province_list = [NONE_SENTINEL, *obj_holidays.subdivisions]
+ province_list = [NONE_SENTINEL, *all_countries[country]]
add_schema = {
vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector(
SelectSelectorConfig(
@@ -74,18 +73,16 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None:
"""Validate custom dates for add/remove holidays."""
for add_date in user_input[CONF_ADD_HOLIDAYS]:
- if dt.parse_date(add_date) is None:
+ if dt_util.parse_date(add_date) is None:
raise AddDatesError("Incorrect date")
- year: int = dt.now().year
- obj_holidays: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY])(years=year)
- if user_input.get(CONF_PROVINCE):
- obj_holidays = getattr(holidays, user_input[CONF_COUNTRY])(
- subdiv=user_input[CONF_PROVINCE], years=year
- )
+ year: int = dt_util.now().year
+ obj_holidays = country_holidays(
+ user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), year
+ )
for remove_date in user_input[CONF_REMOVE_HOLIDAYS]:
- if dt.parse_date(remove_date) is None:
+ if dt_util.parse_date(remove_date) is None:
if obj_holidays.get_named(remove_date) == []:
raise RemoveDatesError("Incorrect date or name")
@@ -95,7 +92,7 @@ DATA_SCHEMA_SETUP = vol.Schema(
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_COUNTRY): SelectSelector(
SelectSelectorConfig(
- options=list(holidays.list_supported_countries()),
+ options=list(list_supported_countries()),
mode=SelectSelectorMode.DROPDOWN,
)
),
@@ -172,8 +169,17 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN):
}
new_config = config.copy()
new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE)
+ LOGGER.debug("Importing with %s", new_config)
self._async_abort_entries_match(abort_match)
+
+ self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME)
+ self.data[CONF_COUNTRY] = config[CONF_COUNTRY]
+ LOGGER.debug(
+ "No duplicate, next step with name %s for country %s",
+ self.data[CONF_NAME],
+ self.data[CONF_COUNTRY],
+ )
return await self.async_step_options(user_input=new_config)
async def async_step_user(
@@ -221,9 +227,12 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS],
CONF_PROVINCE: combined_input[CONF_PROVINCE],
}
-
+ LOGGER.debug("abort_check in options with %s", combined_input)
self._async_abort_entries_match(abort_match)
+
+ LOGGER.debug("Errors have occurred %s", errors)
if not errors:
+ LOGGER.debug("No duplicate, no errors, creating entry")
return self.async_create_entry(
title=combined_input[CONF_NAME],
data={},
@@ -231,13 +240,14 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN):
)
schema = await self.hass.async_add_executor_job(
- add_province_to_schema, DATA_SCHEMA_OPT, self.data
+ add_province_to_schema, DATA_SCHEMA_OPT, self.data[CONF_COUNTRY]
)
new_schema = self.add_suggested_values_to_schema(schema, user_input)
return self.async_show_form(
step_id="options",
data_schema=new_schema,
errors=errors,
+ description_placeholders={"name": self.data[CONF_NAME]},
)
@@ -264,6 +274,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry):
except RemoveDatesError:
errors["remove_holidays"] = "remove_holiday_error"
else:
+ LOGGER.debug("abort_check in options with %s", combined_input)
try:
self._async_abort_entries_match(
{
@@ -282,13 +293,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry):
return self.async_create_entry(data=combined_input)
schema: vol.Schema = await self.hass.async_add_executor_job(
- add_province_to_schema, DATA_SCHEMA_OPT, self.options
+ add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY]
)
new_schema = self.add_suggested_values_to_schema(
schema, user_input or self.options
)
-
+ LOGGER.debug("Errors have occurred in options %s", errors)
return self.async_show_form(
step_id="init",
data_schema=new_schema,
diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json
index 61f59fe06d63..b81e027bb051 100644
--- a/homeassistant/components/workday/strings.json
+++ b/homeassistant/components/workday/strings.json
@@ -2,7 +2,7 @@
"config": {
"abort": {
"incorrect_province": "Incorrect subdivision from yaml import",
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "already_configured": "Workday has already been setup with chosen configuration"
},
"step": {
"user": {
@@ -12,6 +12,7 @@
}
},
"options": {
+ "description": "Set workday options for {name}",
"data": {
"excludes": "Excludes",
"days_offset": "Offset",
@@ -26,7 +27,7 @@
"workdays": "List of workdays",
"add_holidays": "Add custom holidays as YYYY-MM-DD",
"remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name",
- "province": "State, Terroritory, Province, Region of Country"
+ "province": "State, Territory, Province, Region of Country"
}
}
},
diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py
index 8d3f65345027..3f5487881a32 100644
--- a/homeassistant/components/wyoming/stt.py
+++ b/homeassistant/components/wyoming/stt.py
@@ -1,4 +1,4 @@
-"""Support for Wyoming speech to text services."""
+"""Support for Wyoming speech-to-text services."""
from collections.abc import AsyncIterable
import logging
@@ -23,7 +23,7 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up Wyoming speech to text."""
+ """Set up Wyoming speech-to-text."""
service: WyomingService = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
[
@@ -33,7 +33,7 @@ async def async_setup_entry(
class WyomingSttProvider(stt.SpeechToTextEntity):
- """Wyoming speech to text provider."""
+ """Wyoming speech-to-text provider."""
def __init__(
self,
diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py
index f2e314dc13ea..0fc7bf5e6c4c 100644
--- a/homeassistant/components/wyoming/tts.py
+++ b/homeassistant/components/wyoming/tts.py
@@ -1,4 +1,4 @@
-"""Support for Wyoming text to speech services."""
+"""Support for Wyoming text-to-speech services."""
from collections import defaultdict
import io
import logging
@@ -25,7 +25,7 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
- """Set up Wyoming speech to text."""
+ """Set up Wyoming speech-to-text."""
service: WyomingService = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
[
@@ -35,7 +35,7 @@ async def async_setup_entry(
class WyomingTtsProvider(tts.TextToSpeechEntity):
- """Wyoming text to speech provider."""
+ """Wyoming text-to-speech provider."""
def __init__(
self,
@@ -94,7 +94,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
"""Return a list of supported voices for a language."""
return self._voices.get(language)
- async def async_get_tts_audio(self, message, language, options=None):
+ async def async_get_tts_audio(self, message, language, options):
"""Load TTS from UNIX socket."""
try:
async with AsyncTcpClient(self.service.host, self.service.port) as client:
@@ -129,7 +129,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
except (OSError, WyomingError):
return (None, None)
- if (options is None) or (options[tts.ATTR_AUDIO_OUTPUT] == "wav"):
+ if options[tts.ATTR_AUDIO_OUTPUT] == "wav":
return ("wav", data)
# Raw output (convert to 16Khz, 16-bit mono)
diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py
index f51b1a2972ba..16c3fa547314 100644
--- a/homeassistant/components/xiaomi_aqara/__init__.py
+++ b/homeassistant/components/xiaomi_aqara/__init__.py
@@ -354,7 +354,7 @@ class XiaomiDevice(Entity):
def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None:
"""Push from Hub running in another thread."""
- self.hass.loop.call_soon(self.async_push_data, data, raw_data)
+ self.hass.loop.call_soon_threadsafe(self.async_push_data, data, raw_data)
@callback
def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None:
diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py
index 773e63d92bfb..2fae1796e45e 100644
--- a/homeassistant/components/xiaomi_aqara/config_flow.py
+++ b/homeassistant/components/xiaomi_aqara/config_flow.py
@@ -105,7 +105,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_settings()
- # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs.
+ # Discover Xiaomi Aqara Gateways in the network to get required SIDs.
xiaomi = XiaomiGatewayDiscovery(self.interface)
try:
await self.hass.async_add_executor_job(xiaomi.discover_gateways)
diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json
index 4d5cddd95172..69a95ea8a9c4 100644
--- a/homeassistant/components/xiaomi_ble/manifest.json
+++ b/homeassistant/components/xiaomi_ble/manifest.json
@@ -16,5 +16,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"iot_class": "local_push",
- "requirements": ["xiaomi-ble==0.17.0"]
+ "requirements": ["xiaomi-ble==0.17.2"]
}
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index fc38d75d2ca1..ed1bdef9e331 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import color, dt
+from homeassistant.util import color, dt as dt_util
from .const import (
CONF_DEVICE,
@@ -362,7 +362,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight):
delayed_turn_off = self.delayed_turn_off_timestamp(
state.delay_off_countdown,
- dt.utcnow(),
+ dt_util.utcnow(),
self._state_attrs[ATTR_DELAYED_TURN_OFF],
)
@@ -523,7 +523,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight):
delayed_turn_off = self.delayed_turn_off_timestamp(
state.delay_off_countdown,
- dt.utcnow(),
+ dt_util.utcnow(),
self._state_attrs[ATTR_DELAYED_TURN_OFF],
)
@@ -582,7 +582,7 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb):
delayed_turn_off = self.delayed_turn_off_timestamp(
state.delay_off_countdown,
- dt.utcnow(),
+ dt_util.utcnow(),
self._state_attrs[ATTR_DELAYED_TURN_OFF],
)
@@ -625,7 +625,7 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight):
delayed_turn_off = self.delayed_turn_off_timestamp(
state.delay_off_countdown,
- dt.utcnow(),
+ dt_util.utcnow(),
self._state_attrs[ATTR_DELAYED_TURN_OFF],
)
diff --git a/homeassistant/components/yale_home/__init__.py b/homeassistant/components/yale_home/__init__.py
new file mode 100644
index 000000000000..8313f40e785a
--- /dev/null
+++ b/homeassistant/components/yale_home/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Yale Home."""
diff --git a/homeassistant/components/yale_home/manifest.json b/homeassistant/components/yale_home/manifest.json
new file mode 100644
index 000000000000..0e45b0da7d03
--- /dev/null
+++ b/homeassistant/components/yale_home/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "yale_home",
+ "name": "Yale Home",
+ "integration_type": "virtual",
+ "supported_by": "august"
+}
diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py
index 49cd10f4c64b..2aad449a3f78 100644
--- a/homeassistant/components/yale_smart_alarm/binary_sensor.py
+++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py
@@ -20,25 +20,25 @@ SENSOR_TYPES = (
key="acfail",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
- name="Power loss",
+ translation_key="power_loss",
),
BinarySensorEntityDescription(
key="battery",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
- name="Battery",
+ translation_key="battery",
),
BinarySensorEntityDescription(
key="tamper",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
- name="Tamper",
+ translation_key="tamper",
),
BinarySensorEntityDescription(
key="jam",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
- name="Jam",
+ translation_key="jam",
),
)
diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py
index d8601ec85f9a..901cd1863eec 100644
--- a/homeassistant/components/yale_smart_alarm/button.py
+++ b/homeassistant/components/yale_smart_alarm/button.py
@@ -15,7 +15,7 @@ from .entity import YaleAlarmEntity
BUTTON_TYPES = (
ButtonEntityDescription(
key="panic",
- name="Panic button",
+ translation_key="panic",
icon="mdi:alarm-light",
),
)
diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json
index 6a6443c1b9b6..5928013e0982 100644
--- a/homeassistant/components/yale_smart_alarm/strings.json
+++ b/homeassistant/components/yale_smart_alarm/strings.json
@@ -39,5 +39,26 @@
"error": {
"code_format_mismatch": "The code does not match the required number of digits"
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "power_loss": {
+ "name": "Power loss"
+ },
+ "battery": {
+ "name": "Battery"
+ },
+ "tamper": {
+ "name": "Tamper"
+ },
+ "jam": {
+ "name": "Jam"
+ }
+ },
+ "button": {
+ "panic": {
+ "name": "Panic button"
+ }
+ }
}
}
diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py
index 4e9b75137459..11516015b6c0 100644
--- a/homeassistant/components/yalexs_ble/__init__.py
+++ b/homeassistant/components/yalexs_ble/__init__.py
@@ -19,7 +19,14 @@ from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN
+from .const import (
+ CONF_ALWAYS_CONNECTED,
+ CONF_KEY,
+ CONF_LOCAL_NAME,
+ CONF_SLOT,
+ DEVICE_TIMEOUT,
+ DOMAIN,
+)
from .models import YaleXSBLEData
from .util import async_find_existing_service_info, bluetooth_callback_matcher
@@ -33,7 +40,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
key = entry.data[CONF_KEY]
slot = entry.data[CONF_SLOT]
has_unique_local_name = local_name_is_unique(local_name)
- push_lock = PushLock(local_name, address, None, key, slot)
+ always_connected = entry.options.get(CONF_ALWAYS_CONNECTED, False)
+ push_lock = PushLock(
+ local_name, address, None, key, slot, always_connected=always_connected
+ )
id_ = local_name if has_unique_local_name else address
push_lock.set_name(f"{entry.title} ({id_})")
@@ -79,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) from ex
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData(
- entry.title, push_lock
+ entry.title, push_lock, always_connected
)
@callback
@@ -115,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id]
- if entry.title != data.title:
+ if entry.title != data.title or data.always_connected != entry.options.get(
+ CONF_ALWAYS_CONNECTED
+ ):
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py
index b87066b50fe8..3a6d91c4f552 100644
--- a/homeassistant/components/yalexs_ble/config_flow.py
+++ b/homeassistant/components/yalexs_ble/config_flow.py
@@ -23,10 +23,11 @@ from homeassistant.components.bluetooth import (
async_discovered_service_info,
)
from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers.typing import DiscoveryInfoType
-from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
+from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
from .util import async_find_existing_service_info, human_readable_name
_LOGGER = logging.getLogger(__name__)
@@ -297,3 +298,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=data_schema,
errors=errors,
)
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: config_entries.ConfigEntry,
+ ) -> YaleXSBLEOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return YaleXSBLEOptionsFlowHandler(config_entry)
+
+
+class YaleXSBLEOptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle YaleXSBLE options."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
+ """Initialize YaleXSBLE options flow."""
+ self.entry = config_entry
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Manage the YaleXSBLE options."""
+ return await self.async_step_device_options()
+
+ async def async_step_device_options(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Manage the YaleXSBLE devices options."""
+ if user_input is not None:
+ return self.async_create_entry(
+ data={CONF_ALWAYS_CONNECTED: user_input[CONF_ALWAYS_CONNECTED]},
+ )
+
+ return self.async_show_form(
+ step_id="device_options",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_ALWAYS_CONNECTED,
+ default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False),
+ ): bool,
+ }
+ ),
+ )
diff --git a/homeassistant/components/yalexs_ble/const.py b/homeassistant/components/yalexs_ble/const.py
index f38a376a7170..18555f91075c 100644
--- a/homeassistant/components/yalexs_ble/const.py
+++ b/homeassistant/components/yalexs_ble/const.py
@@ -5,5 +5,6 @@ DOMAIN = "yalexs_ble"
CONF_LOCAL_NAME = "local_name"
CONF_KEY = "key"
CONF_SLOT = "slot"
+CONF_ALWAYS_CONNECTED = "always_connected"
DEVICE_TIMEOUT = 55
diff --git a/homeassistant/components/yalexs_ble/models.py b/homeassistant/components/yalexs_ble/models.py
index d79668f1c70b..3b83b52cf739 100644
--- a/homeassistant/components/yalexs_ble/models.py
+++ b/homeassistant/components/yalexs_ble/models.py
@@ -12,3 +12,4 @@ class YaleXSBLEData:
title: str
lock: PushLock
+ always_connected: bool
diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json
index c2d1a2155c3a..bd96e07f6bac 100644
--- a/homeassistant/components/yalexs_ble/strings.json
+++ b/homeassistant/components/yalexs_ble/strings.json
@@ -35,5 +35,15 @@
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
+ },
+ "options": {
+ "step": {
+ "device_options": {
+ "description": "If the lock does not support push updates via advertisements or you want lock operation to be more responsive, you can enable always connected mode. Always connected will cause the lock to stay connected to Home Assistant via Bluetooth, which will use more battery.",
+ "data": {
+ "always_connected": "Always connected"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py
index 4cbbc679e423..755207c272d3 100644
--- a/homeassistant/components/yandextts/tts.py
+++ b/homeassistant/components/yandextts/tts.py
@@ -114,11 +114,10 @@ class YandexSpeechKitProvider(Provider):
"""Return list of supported options."""
return SUPPORTED_OPTIONS
- async def async_get_tts_audio(self, message, language, options=None):
+ async def async_get_tts_audio(self, message, language, options):
"""Load TTS from yandex."""
websession = async_get_clientsession(self.hass)
actual_language = language
- options = options or {}
try:
async with async_timeout.timeout(10):
diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py
index 8eb2991c9dc1..dc4283b4a765 100644
--- a/homeassistant/components/yeelight/scanner.py
+++ b/homeassistant/components/yeelight/scanner.py
@@ -106,7 +106,7 @@ class YeelightScanner:
await self._async_wait_connected()
self._track_interval = async_track_time_interval(
- self._hass, self.async_scan, DISCOVERY_INTERVAL
+ self._hass, self.async_scan, DISCOVERY_INTERVAL, cancel_on_shutdown=True
)
self.async_scan()
diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py
index 7c11b54efa58..a7f52e801b2c 100644
--- a/homeassistant/components/yolink/light.py
+++ b/homeassistant/components/yolink/light.py
@@ -51,9 +51,9 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity):
@callback
def update_entity_state(self, state: dict[str, Any]) -> None:
"""Update HA Entity State."""
- if (dimmer_is_on := state.get("state")) is not None:
+ if (dimmer_state := state.get("state")) is not None:
# update _attr_is_on when device report it's state
- self._attr_is_on = dimmer_is_on
+ self._attr_is_on = dimmer_state == "open"
if (brightness := state.get("brightness")) is not None:
self._attr_brightness = round(255 * brightness / 100)
self.async_write_ha_state()
diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json
index 2353afe5f696..088ddd114f8f 100644
--- a/homeassistant/components/yolink/manifest.json
+++ b/homeassistant/components/yolink/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
- "requirements": ["yolink-api==0.2.8"]
+ "requirements": ["yolink-api==0.2.9"]
}
diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py
index 5f89f54ccbe7..75c4949859cb 100644
--- a/homeassistant/components/yolink/sensor.py
+++ b/homeassistant/components/yolink/sensor.py
@@ -14,6 +14,7 @@ from yolink.const import (
ATTR_DEVICE_MOTION_SENSOR,
ATTR_DEVICE_MULTI_OUTLET,
ATTR_DEVICE_OUTLET,
+ ATTR_DEVICE_POWER_FAILURE_ALARM,
ATTR_DEVICE_SIREN,
ATTR_DEVICE_SMART_REMOTER,
ATTR_DEVICE_SWITCH,
@@ -71,6 +72,7 @@ SENSOR_DEVICE_TYPE = [
ATTR_DEVICE_MULTI_OUTLET,
ATTR_DEVICE_SMART_REMOTER,
ATTR_DEVICE_OUTLET,
+ ATTR_DEVICE_POWER_FAILURE_ALARM,
ATTR_DEVICE_SIREN,
ATTR_DEVICE_SWITCH,
ATTR_DEVICE_TH_SENSOR,
@@ -86,6 +88,7 @@ BATTERY_POWER_SENSOR = [
ATTR_DEVICE_DOOR_SENSOR,
ATTR_DEVICE_LEAK_SENSOR,
ATTR_DEVICE_MOTION_SENSOR,
+ ATTR_DEVICE_POWER_FAILURE_ALARM,
ATTR_DEVICE_SMART_REMOTER,
ATTR_DEVICE_TH_SENSOR,
ATTR_DEVICE_VIBRATION_SENSOR,
@@ -110,6 +113,14 @@ def cvt_battery(val: int | None) -> int | None:
return 0
+def cvt_volume(val: int | None) -> str | None:
+ """Convert volume to string."""
+ if val is None:
+ return None
+ volume_level = {1: "low", 2: "medium", 3: "high"}
+ return volume_level.get(val, None)
+
+
SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
YoLinkSensorEntityDescription(
key="battery",
@@ -157,6 +168,41 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
should_update_entity=lambda value: value is not None,
),
+ YoLinkSensorEntityDescription(
+ key="state",
+ device_class=SensorDeviceClass.ENUM,
+ name="Power failure alarm",
+ icon="mdi:flash",
+ options=["normal", "alert", "off"],
+ exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM,
+ ),
+ YoLinkSensorEntityDescription(
+ key="mute",
+ device_class=SensorDeviceClass.ENUM,
+ name="Power failure alarm mute",
+ icon="mdi:volume-mute",
+ options=["muted", "unmuted"],
+ exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM,
+ value=lambda value: "muted" if value is True else "unmuted",
+ ),
+ YoLinkSensorEntityDescription(
+ key="sound",
+ device_class=SensorDeviceClass.ENUM,
+ name="Power failure alarm volume",
+ icon="mdi:volume-high",
+ options=["low", "medium", "high"],
+ exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM,
+ value=cvt_volume,
+ ),
+ YoLinkSensorEntityDescription(
+ key="beep",
+ device_class=SensorDeviceClass.ENUM,
+ name="Power failure alarm beep",
+ icon="mdi:bullhorn",
+ options=["enabled", "disabled"],
+ exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM,
+ value=lambda value: "enabled" if value is True else "disabled",
+ ),
)
diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py
new file mode 100644
index 000000000000..b120359c0d78
--- /dev/null
+++ b/homeassistant/components/youtube/__init__.py
@@ -0,0 +1,55 @@
+"""Support for YouTube."""
+from __future__ import annotations
+
+from aiohttp.client_exceptions import ClientError, ClientResponseError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+
+from .api import AsyncConfigEntryAuth
+from .const import AUTH, COORDINATOR, DOMAIN
+from .coordinator import YouTubeDataUpdateCoordinator
+
+PLATFORMS = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up YouTube from a config entry."""
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ session = OAuth2Session(hass, entry, implementation)
+ auth = AsyncConfigEntryAuth(hass, async_get_clientsession(hass), session)
+ try:
+ await auth.check_and_refresh_token()
+ except ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed(
+ "OAuth session is not valid, reauth required"
+ ) from err
+ raise ConfigEntryNotReady from err
+ except ClientError as err:
+ raise ConfigEntryNotReady from err
+ coordinator = YouTubeDataUpdateCoordinator(hass, auth)
+
+ await coordinator.async_config_entry_first_refresh()
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
+ COORDINATOR: coordinator,
+ AUTH: auth,
+ }
+ await hass.config_entries.async_forward_entry_setups(entry, list(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
diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py
new file mode 100644
index 000000000000..b0d0bde2baa7
--- /dev/null
+++ b/homeassistant/components/youtube/api.py
@@ -0,0 +1,52 @@
+"""API for YouTube bound to Home Assistant OAuth."""
+from aiohttp import ClientSession
+from google.auth.exceptions import RefreshError
+from google.oauth2.credentials import Credentials
+from google.oauth2.utils import OAuthClientAuthHandler
+from googleapiclient.discovery import Resource, build
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_entry_oauth2_flow
+
+
+class AsyncConfigEntryAuth(OAuthClientAuthHandler):
+ """Provide Google authentication tied to an OAuth2 based config entry."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ websession: ClientSession,
+ oauth2_session: config_entry_oauth2_flow.OAuth2Session,
+ ) -> None:
+ """Initialize YouTube Auth."""
+ self.oauth_session = oauth2_session
+ self.hass = hass
+ super().__init__(websession)
+
+ @property
+ def access_token(self) -> str:
+ """Return the access token."""
+ return self.oauth_session.token[CONF_ACCESS_TOKEN]
+
+ async def check_and_refresh_token(self) -> str:
+ """Check the token."""
+ await self.oauth_session.async_ensure_token_valid()
+ return self.access_token
+
+ async def get_resource(self) -> Resource:
+ """Create executor job to get current resource."""
+ try:
+ credentials = Credentials(await self.check_and_refresh_token())
+ except RefreshError as ex:
+ self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
+ raise ex
+ return await self.hass.async_add_executor_job(self._get_resource, credentials)
+
+ def _get_resource(self, credentials: Credentials) -> Resource:
+ """Get current resource."""
+ return build(
+ "youtube",
+ "v3",
+ credentials=credentials,
+ )
diff --git a/homeassistant/components/youtube/application_credentials.py b/homeassistant/components/youtube/application_credentials.py
new file mode 100644
index 000000000000..ba6188d9a3dc
--- /dev/null
+++ b/homeassistant/components/youtube/application_credentials.py
@@ -0,0 +1,11 @@
+"""application_credentials platform for YouTube."""
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ "https://accounts.google.com/o/oauth2/v2/auth",
+ "https://oauth2.googleapis.com/token",
+ )
diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py
new file mode 100644
index 000000000000..a2adebc84af0
--- /dev/null
+++ b/homeassistant/components/youtube/config_flow.py
@@ -0,0 +1,209 @@
+"""Config flow for YouTube integration."""
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from google.oauth2.credentials import Credentials
+from googleapiclient.discovery import Resource, build
+from googleapiclient.errors import HttpError
+from googleapiclient.http import HttpRequest
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+)
+
+from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER
+
+
+async def get_resource(hass: HomeAssistant, token: str) -> Resource:
+ """Get Youtube resource async."""
+
+ def _build_resource() -> Resource:
+ return build(
+ "youtube",
+ "v3",
+ credentials=Credentials(token),
+ )
+
+ return await hass.async_add_executor_job(_build_resource)
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Google OAuth2 authentication."""
+
+ _data: dict[str, Any] = {}
+ _title: str = ""
+
+ DOMAIN = DOMAIN
+
+ reauth_entry: ConfigEntry | None = None
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> YouTubeOptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return YouTubeOptionsFlowHandler(config_entry)
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {
+ "scope": " ".join(DEFAULT_ACCESS),
+ # Add params to ensure we get back a refresh token
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+
+ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
+ """Perform reauth upon an API authentication error."""
+ self.reauth_entry = self.hass.config_entries.async_get_entry(
+ self.context["entry_id"]
+ )
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
+
+ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
+ """Create an entry for the flow, or update existing entry."""
+ try:
+ service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+ # pylint: disable=no-member
+ own_channel_request: HttpRequest = service.channels().list(
+ part="snippet", mine=True
+ )
+ response = await self.hass.async_add_executor_job(
+ own_channel_request.execute
+ )
+ own_channel = response["items"][0]
+ except HttpError as ex:
+ error = ex.reason
+ return self.async_abort(
+ reason="access_not_configured",
+ description_placeholders={"message": error},
+ )
+ except Exception as ex: # pylint: disable=broad-except
+ LOGGER.error("Unknown error occurred: %s", ex.args)
+ return self.async_abort(reason="unknown")
+ self._title = own_channel["snippet"]["title"]
+ self._data = data
+
+ if not self.reauth_entry:
+ await self.async_set_unique_id(own_channel["id"])
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_channels()
+
+ if self.reauth_entry.unique_id == own_channel["id"]:
+ self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
+ await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+
+ return self.async_abort(
+ reason="wrong_account",
+ description_placeholders={"title": self._title},
+ )
+
+ async def async_step_channels(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Select which channels to track."""
+ if user_input:
+ return self.async_create_entry(
+ title=self._title,
+ data=self._data,
+ options=user_input,
+ )
+ service = await get_resource(
+ self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]
+ )
+ # pylint: disable=no-member
+ subscription_request: HttpRequest = service.subscriptions().list(
+ part="snippet", mine=True, maxResults=50
+ )
+ response = await self.hass.async_add_executor_job(subscription_request.execute)
+ selectable_channels = [
+ SelectOptionDict(
+ value=subscription["snippet"]["resourceId"]["channelId"],
+ label=subscription["snippet"]["title"],
+ )
+ for subscription in response["items"]
+ ]
+ return self.async_show_form(
+ step_id="channels",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_CHANNELS): SelectSelector(
+ SelectSelectorConfig(options=selectable_channels, multiple=True)
+ ),
+ }
+ ),
+ )
+
+
+class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry):
+ """YouTube Options flow handler."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Initialize form."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title=self.config_entry.title,
+ data=user_input,
+ )
+ service = await get_resource(
+ self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
+ )
+ # pylint: disable=no-member
+ subscription_request: HttpRequest = service.subscriptions().list(
+ part="snippet", mine=True, maxResults=50
+ )
+ response = await self.hass.async_add_executor_job(subscription_request.execute)
+ selectable_channels = [
+ SelectOptionDict(
+ value=subscription["snippet"]["resourceId"]["channelId"],
+ label=subscription["snippet"]["title"],
+ )
+ for subscription in response["items"]
+ ]
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_CHANNELS): SelectSelector(
+ SelectSelectorConfig(
+ options=selectable_channels, multiple=True
+ )
+ ),
+ }
+ ),
+ self.options,
+ ),
+ )
diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py
new file mode 100644
index 000000000000..e2757e3856d4
--- /dev/null
+++ b/homeassistant/components/youtube/const.py
@@ -0,0 +1,22 @@
+"""Constants for YouTube integration."""
+import logging
+
+DEFAULT_ACCESS = ["https://www.googleapis.com/auth/youtube.readonly"]
+DOMAIN = "youtube"
+MANUFACTURER = "Google, Inc."
+
+CONF_CHANNELS = "channels"
+CONF_ID = "id"
+CONF_UPLOAD_PLAYLIST = "upload_playlist_id"
+COORDINATOR = "coordinator"
+AUTH = "auth"
+
+LOGGER = logging.getLogger(__package__)
+
+ATTR_TITLE = "title"
+ATTR_LATEST_VIDEO = "latest_video"
+ATTR_SUBSCRIBER_COUNT = "subscriber_count"
+ATTR_DESCRIPTION = "description"
+ATTR_THUMBNAIL = "thumbnail"
+ATTR_VIDEO_ID = "video_id"
+ATTR_PUBLISHED_AT = "published_at"
diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py
new file mode 100644
index 000000000000..190e79e33931
--- /dev/null
+++ b/homeassistant/components/youtube/coordinator.py
@@ -0,0 +1,93 @@
+"""DataUpdateCoordinator for the YouTube integration."""
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Any
+
+from googleapiclient.discovery import Resource
+from googleapiclient.http import HttpRequest
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ICON, ATTR_ID
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from . import AsyncConfigEntryAuth
+from .const import (
+ ATTR_DESCRIPTION,
+ ATTR_LATEST_VIDEO,
+ ATTR_PUBLISHED_AT,
+ ATTR_SUBSCRIBER_COUNT,
+ ATTR_THUMBNAIL,
+ ATTR_TITLE,
+ ATTR_VIDEO_ID,
+ CONF_CHANNELS,
+ DOMAIN,
+ LOGGER,
+)
+
+
+def get_upload_playlist_id(channel_id: str) -> str:
+ """Return the playlist id with the uploads of the channel.
+
+ Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is
+ the way to do it without extra request (UUxxxxxxxxxxxx).
+ """
+ return channel_id.replace("UC", "UU", 1)
+
+
+class YouTubeDataUpdateCoordinator(DataUpdateCoordinator):
+ """A YouTube Data Update Coordinator."""
+
+ config_entry: ConfigEntry
+
+ def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None:
+ """Initialize the YouTube data coordinator."""
+ self._auth = auth
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(minutes=15),
+ )
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ service = await self._auth.get_resource()
+ channels = self.config_entry.options[CONF_CHANNELS]
+ channel_request: HttpRequest = service.channels().list(
+ part="snippet,statistics", id=",".join(channels), maxResults=50
+ )
+ response: dict = await self.hass.async_add_executor_job(channel_request.execute)
+
+ return await self.hass.async_add_executor_job(
+ self._get_channel_data, service, response["items"]
+ )
+
+ def _get_channel_data(
+ self, service: Resource, channels: list[dict[str, Any]]
+ ) -> dict[str, Any]:
+ data: dict[str, Any] = {}
+ for channel in channels:
+ playlist_id = get_upload_playlist_id(channel["id"])
+ response = (
+ service.playlistItems()
+ .list(
+ part="snippet,contentDetails", playlistId=playlist_id, maxResults=1
+ )
+ .execute()
+ )
+ video = response["items"][0]
+ data[channel["id"]] = {
+ ATTR_ID: channel["id"],
+ ATTR_TITLE: channel["snippet"]["title"],
+ ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"],
+ ATTR_LATEST_VIDEO: {
+ ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"],
+ ATTR_TITLE: video["snippet"]["title"],
+ ATTR_DESCRIPTION: video["snippet"]["description"],
+ ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"],
+ ATTR_VIDEO_ID: video["contentDetails"]["videoId"],
+ },
+ ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]),
+ }
+ return data
diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py
new file mode 100644
index 000000000000..2f9238dec267
--- /dev/null
+++ b/homeassistant/components/youtube/entity.py
@@ -0,0 +1,35 @@
+"""Entity representing a YouTube account."""
+from __future__ import annotations
+
+from homeassistant.helpers.device_registry import DeviceEntryType
+from homeassistant.helpers.entity import DeviceInfo, EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import ATTR_TITLE, DOMAIN, MANUFACTURER
+from .coordinator import YouTubeDataUpdateCoordinator
+
+
+class YouTubeChannelEntity(CoordinatorEntity):
+ """An HA implementation for YouTube entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: YouTubeDataUpdateCoordinator,
+ description: EntityDescription,
+ channel_id: str,
+ ) -> None:
+ """Initialize a YouTube entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = (
+ f"{coordinator.config_entry.entry_id}_{channel_id}_{description.key}"
+ )
+ self._channel_id = channel_id
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel_id}")},
+ manufacturer=MANUFACTURER,
+ name=coordinator.data[channel_id][ATTR_TITLE],
+ )
diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json
new file mode 100644
index 000000000000..fbc02bda0069
--- /dev/null
+++ b/homeassistant/components/youtube/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "youtube",
+ "name": "YouTube",
+ "codeowners": ["@joostlek"],
+ "config_flow": true,
+ "dependencies": ["application_credentials"],
+ "documentation": "https://www.home-assistant.io/integrations/youtube",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "requirements": ["google-api-python-client==2.71.0"]
+}
diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py
new file mode 100644
index 000000000000..c605b9604757
--- /dev/null
+++ b/homeassistant/components/youtube/sensor.py
@@ -0,0 +1,103 @@
+"""Support for YouTube Sensors."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ICON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import YouTubeDataUpdateCoordinator
+from .const import (
+ ATTR_LATEST_VIDEO,
+ ATTR_SUBSCRIBER_COUNT,
+ ATTR_THUMBNAIL,
+ ATTR_TITLE,
+ ATTR_VIDEO_ID,
+ COORDINATOR,
+ DOMAIN,
+)
+from .entity import YouTubeChannelEntity
+
+
+@dataclass
+class YouTubeMixin:
+ """Mixin for required keys."""
+
+ value_fn: Callable[[Any], StateType]
+ entity_picture_fn: Callable[[Any], str]
+ attributes_fn: Callable[[Any], dict[str, Any]] | None
+
+
+@dataclass
+class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin):
+ """Describes YouTube sensor entity."""
+
+
+SENSOR_TYPES = [
+ YouTubeSensorEntityDescription(
+ key="latest_upload",
+ translation_key="latest_upload",
+ icon="mdi:youtube",
+ value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE],
+ entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL],
+ attributes_fn=lambda channel: {
+ ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID]
+ },
+ ),
+ YouTubeSensorEntityDescription(
+ key="subscribers",
+ translation_key="subscribers",
+ icon="mdi:youtube-subscription",
+ native_unit_of_measurement="subscribers",
+ value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT],
+ entity_picture_fn=lambda channel: channel[ATTR_ICON],
+ attributes_fn=None,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the YouTube sensor."""
+ coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
+ COORDINATOR
+ ]
+ async_add_entities(
+ YouTubeSensor(coordinator, sensor_type, channel_id)
+ for channel_id in coordinator.data
+ for sensor_type in SENSOR_TYPES
+ )
+
+
+class YouTubeSensor(YouTubeChannelEntity, SensorEntity):
+ """Representation of a YouTube sensor."""
+
+ entity_description: YouTubeSensorEntityDescription
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the value reported by the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data[self._channel_id])
+
+ @property
+ def entity_picture(self) -> str:
+ """Return the value reported by the sensor."""
+ return self.entity_description.entity_picture_fn(
+ self.coordinator.data[self._channel_id]
+ )
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """Return the extra state attributes."""
+ if self.entity_description.attributes_fn:
+ return self.entity_description.attributes_fn(
+ self.coordinator.data[self._channel_id]
+ )
+ return None
diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json
new file mode 100644
index 000000000000..24369ab26f97
--- /dev/null
+++ b/homeassistant/components/youtube/strings.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "access_not_configured": "Please read the below message we got from Google:\n\n{message}",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "step": {
+ "channels": {
+ "description": "Select the channels you want to add.",
+ "data": {
+ "channels": "YouTube channels"
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "Select the channels you want to add.",
+ "data": {
+ "channels": "YouTube channels"
+ }
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "latest_upload": {
+ "name": "Latest upload"
+ },
+ "subscribers": {
+ "name": "Subscribers"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index 17fb4c5856d1..f12752dc5c3d 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -55,6 +55,7 @@ HOMEKIT_TYPES = [
# Thread based devices
"_hap._udp.local.",
]
+_HOMEKIT_MODEL_SPLITS = (None, " ", "-")
# Top level keys we support matching against in properties that are always matched in
# lower case. ex: ZeroconfServiceInfo.name
@@ -66,7 +67,8 @@ DEFAULT_DEFAULT_INTERFACE = True
DEFAULT_IPV6 = True
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
-HOMEKIT_MODEL = "md"
+HOMEKIT_MODEL_LOWER = "md"
+HOMEKIT_MODEL_UPPER = "MD"
# Property key=value has a max length of 255
# so we use 230 to leave space for key=
@@ -192,7 +194,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
zeroconf_types, homekit_models = await asyncio.gather(
async_get_zeroconf(hass), async_get_homekit(hass)
)
- discovery = ZeroconfDiscovery(hass, zeroconf, zeroconf_types, homekit_models, ipv6)
+ homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups(
+ homekit_models
+ )
+ discovery = ZeroconfDiscovery(
+ hass,
+ zeroconf,
+ zeroconf_types,
+ homekit_model_lookup,
+ homekit_model_matchers,
+ ipv6,
+ )
await discovery.async_setup()
async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None:
@@ -212,6 +224,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
+def _build_homekit_model_lookups(
+ homekit_models: dict[str, HomeKitDiscoveredIntegration]
+) -> tuple[
+ dict[str, HomeKitDiscoveredIntegration],
+ dict[re.Pattern, HomeKitDiscoveredIntegration],
+]:
+ """Build lookups for homekit models."""
+ homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {}
+ homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {}
+
+ for model, discovery in homekit_models.items():
+ if "*" in model or "?" in model or "[" in model:
+ homekit_model_matchers[_compile_fnmatch(model)] = discovery
+ else:
+ homekit_model_lookup[model] = discovery
+
+ return homekit_model_lookup, homekit_model_matchers
+
+
def _get_announced_addresses(
adapters: list[Adapter],
first_ip: bytes | None = None,
@@ -347,14 +378,17 @@ class ZeroconfDiscovery:
hass: HomeAssistant,
zeroconf: HaZeroconf,
zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]],
- homekit_models: dict[str, HomeKitDiscoveredIntegration],
+ homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
+ homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
ipv6: bool,
) -> None:
"""Init discovery."""
self.hass = hass
self.zeroconf = zeroconf
self.zeroconf_types = zeroconf_types
- self.homekit_models = homekit_models
+ self.homekit_model_lookups = homekit_model_lookups
+ self.homekit_model_matchers = homekit_model_matchers
+
self.ipv6 = ipv6
self.async_service_browser: HaAsyncServiceBrowser | None = None
@@ -450,14 +484,14 @@ class ZeroconfDiscovery:
# If we can handle it as a HomeKit discovery, we do that here.
if service_type in HOMEKIT_TYPES and (
- homekit_model := async_get_homekit_discovery_domain(
- self.homekit_models, props
+ homekit_discovery := async_get_homekit_discovery(
+ self.homekit_model_lookups, self.homekit_model_matchers, props
)
):
- domain = homekit_model.domain
+ domain = homekit_discovery.domain
discovery_flow.async_create_flow(
self.hass,
- homekit_model.domain,
+ homekit_discovery.domain,
{"source": config_entries.SOURCE_HOMEKIT},
info,
)
@@ -468,7 +502,7 @@ class ZeroconfDiscovery:
# We only send updates to homekit_controller
# if the device is already paired in order to avoid
# offering a second discovery for the same device
- if not is_homekit_paired(props) and not homekit_model.always_discover:
+ if not is_homekit_paired(props) and not homekit_discovery.always_discover:
# If the device is paired with HomeKit we must send on
# the update to homekit_controller so it can see when
# the 'c#' field is updated. This is used to detect
@@ -513,35 +547,38 @@ class ZeroconfDiscovery:
)
-def async_get_homekit_discovery_domain(
- homekit_models: dict[str, HomeKitDiscoveredIntegration], props: dict[str, Any]
+def async_get_homekit_discovery(
+ homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
+ homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
+ props: dict[str, Any],
) -> HomeKitDiscoveredIntegration | None:
"""Handle a HomeKit discovery.
Return the domain to forward the discovery data to
"""
- model = None
- for key in props:
- if key.lower() == HOMEKIT_MODEL:
- model = props[key]
- break
-
- if model is None:
+ if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)):
return None
- for test_model in homekit_models:
- if (
- model != test_model
- and not model.startswith((f"{test_model} ", f"{test_model}-"))
- and not _memorized_fnmatch(model, test_model)
- ):
- continue
+ assert isinstance(model, str)
- return homekit_models[test_model]
+ for split_str in _HOMEKIT_MODEL_SPLITS:
+ key = (model.split(split_str))[0] if split_str else model
+ if discovery := homekit_model_lookups.get(key):
+ return discovery
+
+ for pattern, discovery in homekit_model_matchers.items():
+ if pattern.match(model):
+ return discovery
return None
+@lru_cache(maxsize=256) # matches to the cache in zeroconf itself
+def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str:
+ """Stringify an IP address."""
+ return str(ip_addr)
+
+
def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
"""Return prepared info from mDNS entries."""
properties: dict[str, Any] = {"_raw": {}}
@@ -569,7 +606,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
host: str | None = None
for ip_addr in ip_addresses:
if not ip_addr.is_link_local and not ip_addr.is_unspecified:
- host = str(ip_addr)
+ host = _stringify_ip_address(ip_addr)
break
if not host:
return None
@@ -577,7 +614,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None:
assert service.server is not None, "server cannot be none if there are addresses"
return ZeroconfServiceInfo(
host=host,
- addresses=[str(ip_addr) for ip_addr in ip_addresses],
+ addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses],
port=service.port,
hostname=service.server,
type=service.type,
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 314a0c9ef78a..85cf503bb0d6 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.58.2"]
+ "requirements": ["zeroconf==0.64.0"]
}
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index 5230d77ce464..91bc2ac42a2b 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowHandler, FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from .core.const import (
CONF_BAUDRATE,
@@ -69,7 +69,7 @@ def _format_backup_choice(
) -> str:
"""Format network backup info into a short piece of text."""
if not pan_ids:
- return dt.as_local(backup.backup_time).strftime("%c")
+ return dt_util.as_local(backup.backup_time).strftime("%c")
identifier = (
# PAN ID
@@ -78,7 +78,7 @@ def _format_backup_choice(
f":{str(backup.network_info.extended_pan_id).replace(':', '')}"
).lower()
- return f"{dt.as_local(backup.backup_time).strftime('%c')} ({identifier})"
+ return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})"
async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
@@ -96,7 +96,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
yellow_radio.manufacturer = "Nabu Casa"
# Present the multi-PAN addon as a setup option, if it's available
- addon_manager = silabs_multiprotocol_addon.get_addon_manager(hass)
+ addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass)
try:
addon_info = await addon_manager.async_get_addon_info()
diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py
index 139acb23923d..311e876bbc06 100644
--- a/homeassistant/components/zha/core/device.py
+++ b/homeassistant/components/zha/core/device.py
@@ -740,9 +740,15 @@ class ZHADevice(LogMixin):
manufacturer=None,
):
"""Write a value to a zigbee attribute for a cluster in this entity."""
- cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type)
- if cluster is None:
- return None
+ try:
+ cluster: Cluster = self.async_get_cluster(
+ endpoint_id, cluster_id, cluster_type
+ )
+ except KeyError as exc:
+ raise ValueError(
+ f"Cluster {cluster_id} not found on endpoint {endpoint_id} while"
+ f" writing attribute {attribute} with value {value}"
+ ) from exc
try:
response = await cluster.write_attributes(
@@ -758,15 +764,13 @@ class ZHADevice(LogMixin):
)
return response
except zigpy.exceptions.ZigbeeException as exc:
- self.debug(
- "failed to set attribute: %s %s %s %s %s",
- f"{ATTR_VALUE}: {value}",
- f"{ATTR_ATTRIBUTE}: {attribute}",
- f"{ATTR_CLUSTER_ID}: {cluster_id}",
- f"{ATTR_ENDPOINT_ID}: {endpoint_id}",
- exc,
- )
- return None
+ raise HomeAssistantError(
+ f"Failed to set attribute: "
+ f"{ATTR_VALUE}: {value} "
+ f"{ATTR_ATTRIBUTE}: {attribute} "
+ f"{ATTR_CLUSTER_ID}: {cluster_id} "
+ f"{ATTR_ENDPOINT_ID}: {endpoint_id}"
+ ) from exc
async def issue_cluster_command(
self,
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 46fe2ce472a6..6e93fca6042e 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -23,7 +23,7 @@
"bellows==0.35.5",
"pyserial==3.5",
"pyserial-asyncio==0.6",
- "zha-quirks==0.0.99",
+ "zha-quirks==0.0.100",
"zigpy-deconz==0.21.0",
"zigpy==0.55.0",
"zigpy-xbee==0.18.0",
diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py
index 6bc6f30a34f4..d24d0c566681 100644
--- a/homeassistant/components/zha/number.py
+++ b/homeassistant/components/zha/number.py
@@ -276,6 +276,7 @@ async def async_setup_entry(
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ZhaNumber(ZhaEntity, NumberEntity):
"""Representation of a ZHA Number entity."""
@@ -376,6 +377,7 @@ class ZhaNumber(ZhaEntity, NumberEntity):
_LOGGER.debug("read value=%s", value)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
"""Representation of a ZHA number configuration entity."""
@@ -458,6 +460,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
cluster_handler_names="opple_cluster",
models={"lumi.motion.ac02", "lumi.motion.agl04"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraMotionDetectionInterval(
ZHANumberConfigurationEntity, id_suffix="detection_interval"
):
@@ -470,6 +473,7 @@ class AqaraMotionDetectionInterval(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class OnOffTransitionTimeConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="on_off_transition_time"
):
@@ -482,6 +486,7 @@ class OnOffTransitionTimeConfigurationEntity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"):
"""Representation of a ZHA on level configuration entity."""
@@ -492,6 +497,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class OnTransitionTimeConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="on_transition_time"
):
@@ -504,6 +510,7 @@ class OnTransitionTimeConfigurationEntity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class OffTransitionTimeConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="off_transition_time"
):
@@ -516,6 +523,7 @@ class OffTransitionTimeConfigurationEntity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class DefaultMoveRateConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="default_move_rate"
):
@@ -528,6 +536,7 @@ class DefaultMoveRateConfigurationEntity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class StartUpCurrentLevelConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="start_up_current_level"
):
@@ -540,6 +549,7 @@ class StartUpCurrentLevelConfigurationEntity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class StartUpColorTemperatureConfigurationEntity(
ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature"
):
@@ -570,6 +580,7 @@ class StartUpColorTemperatureConfigurationEntity(
"_TZE200_htnnfasr",
},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_duration"):
"""Representation of a ZHA timer duration configuration entity."""
@@ -583,6 +594,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"):
"""Representation of a ZHA filter lifetime configuration entity."""
@@ -600,6 +612,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time")
manufacturers={"TexasInstruments"},
models={"ti.router"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_power"):
"""Representation of a ZHA TI transmit power configuration entity."""
@@ -610,6 +623,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliRemoteDimmingUpSpeed(
ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote"
):
@@ -624,6 +638,7 @@ class InovelliRemoteDimmingUpSpeed(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"):
"""Inovelli button delay configuration entity."""
@@ -636,6 +651,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliLocalDimmingUpSpeed(
ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local"
):
@@ -650,6 +666,7 @@ class InovelliLocalDimmingUpSpeed(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliLocalRampRateOffToOn(
ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local"
):
@@ -664,6 +681,7 @@ class InovelliLocalRampRateOffToOn(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliRemoteDimmingSpeedOffToOn(
ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote"
):
@@ -678,6 +696,7 @@ class InovelliRemoteDimmingSpeedOffToOn(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliRemoteDimmingDownSpeed(
ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote"
):
@@ -692,6 +711,7 @@ class InovelliRemoteDimmingDownSpeed(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliLocalDimmingDownSpeed(
ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local"
):
@@ -706,6 +726,7 @@ class InovelliLocalDimmingDownSpeed(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliLocalRampRateOnToOff(
ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local"
):
@@ -720,6 +741,7 @@ class InovelliLocalRampRateOnToOff(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliRemoteDimmingSpeedOnToOff(
ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote"
):
@@ -734,6 +756,7 @@ class InovelliRemoteDimmingSpeedOnToOff(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliMinimumLoadDimmingLevel(
ZHANumberConfigurationEntity, id_suffix="minimum_level"
):
@@ -748,6 +771,7 @@ class InovelliMinimumLoadDimmingLevel(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliMaximumLoadDimmingLevel(
ZHANumberConfigurationEntity, id_suffix="maximum_level"
):
@@ -762,6 +786,7 @@ class InovelliMaximumLoadDimmingLevel(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliAutoShutoffTimer(
ZHANumberConfigurationEntity, id_suffix="auto_off_timer"
):
@@ -776,6 +801,7 @@ class InovelliAutoShutoffTimer(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliLoadLevelIndicatorTimeout(
ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout"
):
@@ -790,6 +816,7 @@ class InovelliLoadLevelIndicatorTimeout(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDefaultAllLEDOnColor(
ZHANumberConfigurationEntity, id_suffix="led_color_when_on"
):
@@ -804,6 +831,7 @@ class InovelliDefaultAllLEDOnColor(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDefaultAllLEDOffColor(
ZHANumberConfigurationEntity, id_suffix="led_color_when_off"
):
@@ -818,6 +846,7 @@ class InovelliDefaultAllLEDOffColor(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDefaultAllLEDOnIntensity(
ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on"
):
@@ -832,6 +861,7 @@ class InovelliDefaultAllLEDOnIntensity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDefaultAllLEDOffIntensity(
ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off"
):
@@ -846,6 +876,7 @@ class InovelliDefaultAllLEDOffIntensity(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDoubleTapUpLevel(
ZHANumberConfigurationEntity, id_suffix="double_tap_up_level"
):
@@ -860,6 +891,7 @@ class InovelliDoubleTapUpLevel(
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class InovelliDoubleTapDownLevel(
ZHANumberConfigurationEntity, id_suffix="double_tap_down_level"
):
@@ -876,6 +908,7 @@ class InovelliDoubleTapDownLevel(
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"):
"""Aqara pet feeder serving size configuration entity."""
@@ -891,6 +924,7 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederPortionWeight(
ZHANumberConfigurationEntity, id_suffix="portion_weight"
):
@@ -909,6 +943,7 @@ class AqaraPetFeederPortionWeight(
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraThermostatAwayTemp(
ZHANumberConfigurationEntity, id_suffix="away_preset_temperature"
):
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
index 52c1f6a5b193..918458a32ad2 100644
--- a/homeassistant/components/zha/sensor.py
+++ b/homeassistant/components/zha/sensor.py
@@ -115,6 +115,7 @@ async def async_setup_entry(
config_entry.async_on_unload(unsub)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Sensor(ZhaEntity, SensorEntity):
"""Base ZHA sensor."""
@@ -187,6 +188,7 @@ class Sensor(ZhaEntity, SensorEntity):
manufacturers="Digi",
stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AnalogInput(Sensor):
"""Sensor that displays analog input values."""
@@ -195,6 +197,7 @@ class AnalogInput(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Battery(Sensor):
"""Battery sensor of power configuration cluster."""
@@ -253,6 +256,7 @@ class Battery(Sensor):
stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
models={"VZM31-SN", "SP 234", "outletv4"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurement(Sensor):
"""Active power measurement."""
@@ -292,6 +296,7 @@ class ElectricalMeasurement(Sensor):
cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class PolledElectricalMeasurement(ElectricalMeasurement):
"""Polled active power measurement."""
@@ -305,6 +310,7 @@ class PolledElectricalMeasurement(ElectricalMeasurement):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurementApparentPower(
ElectricalMeasurement, id_suffix="apparent_power"
):
@@ -318,6 +324,7 @@ class ElectricalMeasurementApparentPower(
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"):
"""RMS current measurement."""
@@ -329,6 +336,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"):
"""RMS Voltage measurement."""
@@ -340,6 +348,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"):
"""Frequency measurement."""
@@ -351,6 +360,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"):
"""Frequency measurement."""
@@ -368,6 +378,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f
cluster_handler_names=CLUSTER_HANDLER_HUMIDITY,
stop_on_match_group=CLUSTER_HANDLER_HUMIDITY,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Humidity(Sensor):
"""Humidity sensor."""
@@ -380,6 +391,7 @@ class Humidity(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SoilMoisture(Sensor):
"""Soil Moisture sensor."""
@@ -392,6 +404,7 @@ class SoilMoisture(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class LeafWetness(Sensor):
"""Leaf Wetness sensor."""
@@ -404,6 +417,7 @@ class LeafWetness(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Illuminance(Sensor):
"""Illuminance Sensor."""
@@ -422,6 +436,7 @@ class Illuminance(Sensor):
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SmartEnergyMetering(Sensor):
"""Metering sensor."""
@@ -475,6 +490,7 @@ class SmartEnergyMetering(Sensor):
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"):
"""Smart Energy Metering summation sensor."""
@@ -516,6 +532,7 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered")
models={"TS011F", "ZLinky_TIC"},
stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class PolledSmartEnergySummation(SmartEnergySummation):
"""Polled Smart Energy Metering summation sensor."""
@@ -532,6 +549,7 @@ class PolledSmartEnergySummation(SmartEnergySummation):
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier1SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier1_summation_delivered"
):
@@ -545,6 +563,7 @@ class Tier1SmartEnergySummation(
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier2SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier2_summation_delivered"
):
@@ -558,6 +577,7 @@ class Tier2SmartEnergySummation(
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier3SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier3_summation_delivered"
):
@@ -571,6 +591,7 @@ class Tier3SmartEnergySummation(
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier4SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier4_summation_delivered"
):
@@ -584,6 +605,7 @@ class Tier4SmartEnergySummation(
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier5SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier5_summation_delivered"
):
@@ -597,6 +619,7 @@ class Tier5SmartEnergySummation(
cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING,
models={"ZLinky_TIC"},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Tier6SmartEnergySummation(
PolledSmartEnergySummation, id_suffix="tier6_summation_delivered"
):
@@ -607,6 +630,7 @@ class Tier6SmartEnergySummation(
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Pressure(Sensor):
"""Pressure sensor."""
@@ -619,6 +643,7 @@ class Pressure(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class Temperature(Sensor):
"""Temperature Sensor."""
@@ -631,6 +656,7 @@ class Temperature(Sensor):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class DeviceTemperature(Sensor):
"""Device Temperature Sensor."""
@@ -644,6 +670,7 @@ class DeviceTemperature(Sensor):
@MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class CarbonDioxideConcentration(Sensor):
"""Carbon Dioxide Concentration sensor."""
@@ -657,6 +684,7 @@ class CarbonDioxideConcentration(Sensor):
@MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class CarbonMonoxideConcentration(Sensor):
"""Carbon Monoxide Concentration sensor."""
@@ -671,6 +699,7 @@ class CarbonMonoxideConcentration(Sensor):
@MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level")
@MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class VOCLevel(Sensor):
"""VOC Level sensor."""
@@ -688,11 +717,14 @@ class VOCLevel(Sensor):
models="lumi.airmonitor.acn01",
stop_on_match_group="voc_level",
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class PPBVOCLevel(Sensor):
"""VOC Level sensor."""
SENSOR_ATTR = "measured_value"
- _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
+ _attr_device_class: SensorDeviceClass = (
+ SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
+ )
_attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT
_attr_name: str = "VOC level"
_decimals = 0
@@ -701,10 +733,12 @@ class PPBVOCLevel(Sensor):
@MULTI_MATCH(cluster_handler_names="pm25")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class PM25(Sensor):
"""Particulate Matter 2.5 microns or less sensor."""
SENSOR_ATTR = "measured_value"
+ _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25
_attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT
_attr_name: str = "Particulate matter"
_decimals = 0
@@ -713,6 +747,7 @@ class PM25(Sensor):
@MULTI_MATCH(cluster_handler_names="formaldehyde_concentration")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class FormaldehydeConcentration(Sensor):
"""Formaldehyde Concentration sensor."""
@@ -728,6 +763,7 @@ class FormaldehydeConcentration(Sensor):
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ThermostatHVACAction(Sensor, id_suffix="hvac_action"):
"""Thermostat HVAC action sensor."""
@@ -821,6 +857,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"):
manufacturers="Sinope Technologies",
stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT,
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SinopeHVACAction(ThermostatHVACAction):
"""Sinope Thermostat HVAC action sensor."""
@@ -850,6 +887,7 @@ class SinopeHVACAction(ThermostatHVACAction):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class RSSISensor(Sensor, id_suffix="rssi"):
"""RSSI sensor for a device."""
@@ -886,6 +924,7 @@ class RSSISensor(Sensor, id_suffix="rssi"):
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class LQISensor(RSSISensor, id_suffix="lqi"):
"""LQI sensor for a device."""
@@ -900,6 +939,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"):
"_TZE200_htnnfasr",
},
)
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class TimeLeft(Sensor, id_suffix="time_left"):
"""Sensor that displays time left value."""
@@ -911,6 +951,7 @@ class TimeLeft(Sensor, id_suffix="time_left"):
@MULTI_MATCH(cluster_handler_names="ikea_airpurifier")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"):
"""Sensor that displays device run time (in minutes)."""
@@ -922,6 +963,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"):
@MULTI_MATCH(cluster_handler_names="ikea_airpurifier")
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"):
"""Sensor that displays run time of the current filter (in minutes)."""
@@ -940,6 +982,7 @@ class AqaraFeedingSource(types.enum8):
@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"})
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"):
"""Sensor that displays the last feeding source of pet feeder."""
@@ -953,6 +996,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"):
@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"})
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"):
"""Sensor that displays the last feeding size of the pet feeder."""
@@ -962,6 +1006,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"):
@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"})
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"):
"""Sensor that displays the number of portions dispensed by the pet feeder."""
@@ -972,6 +1017,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"):
@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"})
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"):
"""Sensor that displays the weight dispensed by the pet feeder."""
@@ -983,6 +1029,7 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"):
@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"})
+# pylint: disable-next=hass-invalid-inheritance # needs fixing
class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"):
"""Sensor that displays the smoke density of an Aqara smoke sensor in dB/m."""
diff --git a/homeassistant/components/zha/silabs_multiprotocol.py b/homeassistant/components/zha/silabs_multiprotocol.py
new file mode 100644
index 000000000000..aec52b4ac751
--- /dev/null
+++ b/homeassistant/components/zha/silabs_multiprotocol.py
@@ -0,0 +1,81 @@
+"""Silicon Labs Multiprotocol support."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+
+from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
+ is_multiprotocol_url,
+)
+from homeassistant.core import HomeAssistant
+
+from . import api
+
+# The approximate time it takes ZHA to change channels on SiLabs coordinators
+ZHA_CHANNEL_CHANGE_TIME_S = 10.27
+
+
+def _get_zha_url(hass: HomeAssistant) -> str | None:
+ """Return the ZHA radio path, or None if there's no ZHA config entry."""
+ with contextlib.suppress(ValueError):
+ return api.async_get_radio_path(hass)
+ return None
+
+
+async def _get_zha_channel(hass: HomeAssistant) -> int | None:
+ """Get ZHA channel, or None if there's no ZHA config entry."""
+ zha_network_settings: api.NetworkBackup | None
+ with contextlib.suppress(ValueError):
+ zha_network_settings = await api.async_get_network_settings(hass)
+ if not zha_network_settings:
+ return None
+ channel: int = zha_network_settings.network_info.channel
+ # ZHA uses channel 0 when no channel is set
+ return channel or None
+
+
+async def async_change_channel(
+ hass: HomeAssistant, channel: int, delay: float = 0
+) -> asyncio.Task | None:
+ """Set the channel to be used.
+
+ Does nothing if not configured.
+ """
+ zha_url = _get_zha_url(hass)
+ if not zha_url:
+ # ZHA is not configured
+ return None
+
+ async def finish_migration() -> None:
+ """Finish the channel migration."""
+ await asyncio.sleep(max(0, delay - ZHA_CHANNEL_CHANGE_TIME_S))
+ return await api.async_change_channel(hass, channel)
+
+ return hass.async_create_task(finish_migration())
+
+
+async def async_get_channel(hass: HomeAssistant) -> int | None:
+ """Return the channel.
+
+ Returns None if not configured.
+ """
+ zha_url = _get_zha_url(hass)
+ if not zha_url:
+ # ZHA is not configured
+ return None
+
+ return await _get_zha_channel(hass)
+
+
+async def async_using_multipan(hass: HomeAssistant) -> bool:
+ """Return if the multiprotocol device is used.
+
+ Returns False if not configured.
+ """
+ zha_url = _get_zha_url(hass)
+ if not zha_url:
+ # ZHA is not configured
+ return False
+
+ return is_multiprotocol_url(zha_url)
diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py
index 2d4126861b40..28e115c0ec4f 100644
--- a/homeassistant/components/zha/websocket_api.py
+++ b/homeassistant/components/zha/websocket_api.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast
import voluptuous as vol
import zigpy.backups
+from zigpy.config import CONF_DEVICE
from zigpy.config.validators import cv_boolean
from zigpy.types.named import EUI64
from zigpy.zcl.clusters.security import IasAce
@@ -1136,6 +1137,7 @@ async def websocket_get_network_settings(
msg[ID],
{
"radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name,
+ "device": zha_gateway.application_controller.config[CONF_DEVICE],
"settings": backup.as_dict(),
},
)
@@ -1302,6 +1304,9 @@ def async_load_api(hass: HomeAssistant) -> None:
cluster_type=cluster_type,
manufacturer=manufacturer,
)
+ else:
+ raise ValueError(f"Device with IEEE {str(ieee)} not found")
+
_LOGGER.debug(
(
"Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:"
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index 2133c8550dab..b7212e910913 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
+from operator import attrgetter
from typing import Any, cast
from typing_extensions import Self
@@ -96,6 +97,8 @@ RELOAD_SERVICE_SCHEMA = vol.Schema({})
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
+ENTITY_ID_SORTER = attrgetter("entity_id")
+
@bind_hass
def async_active_zone(
@@ -106,15 +109,10 @@ def async_active_zone(
This method must be run in the event loop.
"""
# Sort entity IDs so that we are deterministic if equal distance to 2 zones
- zones = (
- cast(State, hass.states.get(entity_id))
- for entity_id in sorted(hass.states.async_entity_ids(DOMAIN))
- )
-
min_dist = None
closest = None
- for zone in zones:
+ for zone in sorted(hass.states.async_all(DOMAIN), key=ENTITY_ID_SORTER):
if zone.state == STATE_UNAVAILABLE or zone.attributes.get(ATTR_PASSIVE):
continue
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index 66839026dd43..a89d20d8384a 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -321,10 +321,25 @@ class ControllerEvents:
async def async_on_node_added(self, node: ZwaveNode) -> None:
"""Handle node added event."""
+ # Every node including the controller will have at least one sensor
+ await self.driver_events.async_setup_platform(Platform.SENSOR)
+
+ # Remove stale entities that may exist from a previous interview when an
+ # interview is started.
+ base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node)
+ self.config_entry.async_on_unload(
+ node.on(
+ "interview started",
+ lambda _: async_dispatcher_send(
+ self.hass,
+ f"{DOMAIN}_{base_unique_id}_remove_entity_on_interview_started",
+ ),
+ )
+ )
+
# No need for a ping button or node status sensor for controller nodes
if not node.is_controller_node:
# Create a node status sensor for each device
- await self.driver_events.async_setup_platform(Platform.SENSOR)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_node_status_sensor",
@@ -339,6 +354,13 @@ class ControllerEvents:
node,
)
+ # Create statistics sensors for each device
+ async_dispatcher_send(
+ self.hass,
+ f"{DOMAIN}_{self.config_entry.entry_id}_add_statistics_sensors",
+ node,
+ )
+
LOGGER.debug("Node added: %s", node.node_id)
# Listen for ready node events, both new and re-interview.
@@ -455,7 +477,6 @@ class NodeEvents:
async def async_on_node_ready(self, node: ZwaveNode) -> None:
"""Handle node ready event."""
LOGGER.debug("Processing node %s", node)
- driver = self.controller_events.driver_events.driver
# register (or update) node in device registry
device = self.controller_events.register_node_in_dev_reg(node)
# We only want to create the defaultdict once, even on reinterviews
@@ -464,15 +485,6 @@ class NodeEvents:
# Remove any old value ids if this is a reinterview.
self.controller_events.discovered_value_ids.pop(device.id, None)
- # Remove stale entities that may exist from a previous interview.
- async_dispatcher_send(
- self.hass,
- (
- f"{DOMAIN}_"
- f"{get_valueless_base_unique_id(driver, node)}_"
- "remove_entity_on_ready_node"
- ),
- )
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}
diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py
index 29e0dcf9e069..867405530abb 100644
--- a/homeassistant/components/zwave_js/api.py
+++ b/homeassistant/components/zwave_js/api.py
@@ -98,6 +98,7 @@ COMMAND_CLASS_ID = "command_class_id"
TYPE = "type"
PROPERTY = "property"
PROPERTY_KEY = "property_key"
+ENDPOINT = "endpoint"
VALUE = "value"
# constants for log config commands
@@ -1608,6 +1609,7 @@ async def websocket_refresh_node_cc_values(
vol.Required(TYPE): "zwave_js/set_config_parameter",
vol.Required(DEVICE_ID): str,
vol.Required(PROPERTY): int,
+ vol.Optional(ENDPOINT, default=0): int,
vol.Optional(PROPERTY_KEY): int,
vol.Required(VALUE): vol.Any(int, BITMASK_SCHEMA),
}
@@ -1623,12 +1625,13 @@ async def websocket_set_config_parameter(
) -> None:
"""Set a config parameter value for a Z-Wave node."""
property_ = msg[PROPERTY]
+ endpoint = msg[ENDPOINT]
property_key = msg.get(PROPERTY_KEY)
value = msg[VALUE]
try:
zwave_value, cmd_status = await async_set_config_parameter(
- node, value, property_, property_key=property_key
+ node, value, property_, property_key=property_key, endpoint=endpoint
)
except (InvalidNewValue, NotFoundError, NotImplementedError, SetValueFailed) as err:
code = ERR_UNKNOWN_ERROR
@@ -1673,6 +1676,7 @@ async def websocket_get_config_parameters(
result[value_id] = {
"property": zwave_value.property_,
"property_key": zwave_value.property_key,
+ "endpoint": zwave_value.endpoint,
"configuration_value_type": zwave_value.configuration_value_type.value,
"metadata": {
"description": metadata.description,
diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py
index 0051d6ccbf2b..ef5cdd1b1d22 100644
--- a/homeassistant/components/zwave_js/binary_sensor.py
+++ b/homeassistant/components/zwave_js/binary_sensor.py
@@ -315,6 +315,10 @@ async def async_setup_entry(
config_entry, driver, info, property_description
)
)
+ elif info.platform_hint == "config_parameter":
+ entities.append(
+ ZWaveConfigParameterBinarySensor(config_entry, driver, info)
+ )
else:
# boolean sensor
entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info))
@@ -411,3 +415,22 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
if self.info.primary_value.value is None:
return None
return self.info.primary_value.value in self.entity_description.on_states
+
+
+class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor):
+ """Representation of a Z-Wave config parameter binary sensor."""
+
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize a ZWaveConfigParameterBinarySensor entity."""
+ super().__init__(config_entry, driver, info)
+
+ property_key_name = self.info.primary_value.property_key_name
+ # Entity class attributes
+ self._attr_name = self.generate_name(
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[property_key_name] if property_key_name else None,
+ )
diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py
index e743284abddf..33d1e6dfa63d 100644
--- a/homeassistant/components/zwave_js/button.py
+++ b/homeassistant/components/zwave_js/button.py
@@ -36,6 +36,8 @@ async def async_setup_entry(
entities: list[ZWaveBaseEntity] = []
if info.platform_hint == "notification idle":
entities.append(ZWaveNotificationIdleButton(config_entry, driver, info))
+ else:
+ entities.append(ZwaveBooleanNodeButton(config_entry, driver, info))
async_add_entities(entities)
@@ -63,6 +65,21 @@ async def async_setup_entry(
)
+class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity):
+ """Representation of a ZWave button entity for a boolean value."""
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize entity."""
+ super().__init__(config_entry, driver, info)
+ self._attr_name = self.generate_name(include_value_name=True)
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self._async_set_value(self.info.primary_value, True)
+
+
class ZWaveNodePingButton(ButtonEntity):
"""Representation of a ping button entity."""
@@ -83,6 +100,9 @@ class ZWaveNodePingButton(ButtonEntity):
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
+ # We log an error instead of raising an exception because this service call occurs
+ # in a separate task since it is called via the dispatcher and we don't want to
+ # raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
@@ -98,6 +118,9 @@ class ZWaveNodePingButton(ButtonEntity):
)
)
+ # we don't listen for `remove_entity_on_ready_node` signal because this entity
+ # is created when the node is added which occurs before ready. It only needs to
+ # be removed if the node is removed from the network.
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -122,7 +145,9 @@ class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity):
"""Initialize a ZWaveNotificationIdleButton entity."""
super().__init__(config_entry, driver, info)
self._attr_name = self.generate_name(
- include_value_name=True, name_prefix="Idle"
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[self.info.primary_value.property_key_name],
+ name_prefix="Idle",
)
self._attr_unique_id = f"{self._attr_unique_id}.notification_idle"
diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py
index 5397ab3a65b7..82c212a99a52 100644
--- a/homeassistant/components/zwave_js/climate.py
+++ b/homeassistant/components/zwave_js/climate.py
@@ -437,7 +437,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
except StopIteration:
raise ValueError(f"Received an invalid fan mode: {fan_mode}") from None
- await self.info.node.async_set_value(self._fan_mode, new_state)
+ await self._async_set_value(self._fan_mode, new_state)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -451,7 +451,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
)
target_temp: float | None = kwargs.get(ATTR_TEMPERATURE)
if target_temp is not None:
- await self.info.node.async_set_value(setpoint, target_temp)
+ await self._async_set_value(setpoint, target_temp)
elif len(self._current_mode_setpoint_enums) == 2:
setpoint_low: ZwaveValue = self._setpoint_value_or_raise(
self._current_mode_setpoint_enums[0]
@@ -462,9 +462,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
target_temp_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW)
target_temp_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH)
if target_temp_low is not None:
- await self.info.node.async_set_value(setpoint_low, target_temp_low)
+ await self._async_set_value(setpoint_low, target_temp_low)
if target_temp_high is not None:
- await self.info.node.async_set_value(setpoint_high, target_temp_high)
+ await self._async_set_value(setpoint_high, target_temp_high)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -475,7 +475,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
# Thermostat(valve) has no support for setting a mode, so we make it a no-op
return
- await self.info.node.async_set_value(self._current_mode, hvac_mode_id)
+ await self._async_set_value(self._current_mode, hvac_mode_id)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
@@ -487,7 +487,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
preset_mode_value = self._hvac_presets.get(preset_mode)
if preset_mode_value is None:
raise ValueError(f"Received an invalid preset mode: {preset_mode}")
- await self.info.node.async_set_value(self._current_mode, preset_mode_value)
+ await self._async_set_value(self._current_mode, preset_mode_value)
class DynamicCurrentTempClimate(ZWaveClimate):
diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py
index 3967709ccc8b..5ee8b3006037 100644
--- a/homeassistant/components/zwave_js/const.py
+++ b/homeassistant/components/zwave_js/const.py
@@ -1,6 +1,12 @@
"""Constants for the Z-Wave JS integration."""
+from __future__ import annotations
+
import logging
+from zwave_js_server.const.command_class.window_covering import (
+ WindowCoveringPropertyKey,
+)
+
from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION
USER_AGENT = {APPLICATION_NAME: HA_VERSION}
@@ -30,6 +36,9 @@ EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry"
LOGGER = logging.getLogger(__package__)
+# constants extra state attributes
+ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities
+
# constants for events
ZWAVE_JS_VALUE_NOTIFICATION_EVENT = f"{DOMAIN}_value_notification"
ZWAVE_JS_NOTIFICATION_EVENT = f"{DOMAIN}_notification"
@@ -121,11 +130,49 @@ ENTITY_DESC_KEY_PRESSURE = "pressure"
ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength"
ENTITY_DESC_KEY_TEMPERATURE = "temperature"
ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature"
+ENTITY_DESC_KEY_UV_INDEX = "uv_index"
ENTITY_DESC_KEY_MEASUREMENT = "measurement"
ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing"
+ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER = "energy_production_power"
+ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME = "energy_production_time"
+ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL = "energy_production_total"
+ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY = "energy_production_today"
+
# This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for
# your own (https://github.com/zwave-js/firmware-updates/).
API_KEY_FIRMWARE_UPDATE_SERVICE = (
"2e39d98fc56386389fbb35e5a98fa1b44b9fdd8f971460303587cff408430d4cfcde6134"
)
+
+# Platform constants
+# cover
+COVER_POSITION_PROPERTY_KEYS: set[str | int | None] = {
+ WindowCoveringPropertyKey.INBOUND_BOTTOM,
+ WindowCoveringPropertyKey.INBOUND_BOTTOM_NO_POSITION,
+ WindowCoveringPropertyKey.INBOUND_LEFT,
+ WindowCoveringPropertyKey.INBOUND_LEFT_NO_POSITION,
+ WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT,
+ WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT_NO_POSITION,
+ WindowCoveringPropertyKey.INBOUND_RIGHT,
+ WindowCoveringPropertyKey.INBOUND_RIGHT_NO_POSITION,
+ WindowCoveringPropertyKey.INBOUND_TOP,
+ WindowCoveringPropertyKey.INBOUND_TOP_NO_POSITION,
+ WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM,
+ WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM_NO_POSITION,
+ WindowCoveringPropertyKey.OUTBOUND_BOTTOM,
+ WindowCoveringPropertyKey.OUTBOUND_BOTTOM_NO_POSITION,
+ WindowCoveringPropertyKey.OUTBOUND_LEFT,
+ WindowCoveringPropertyKey.OUTBOUND_LEFT_NO_POSITION,
+ WindowCoveringPropertyKey.OUTBOUND_RIGHT,
+ WindowCoveringPropertyKey.OUTBOUND_RIGHT_NO_POSITION,
+ WindowCoveringPropertyKey.OUTBOUND_TOP,
+ WindowCoveringPropertyKey.OUTBOUND_TOP_NO_POSITION,
+}
+
+COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
+ WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE,
+ WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE_NO_POSITION,
+ WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
+ WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
+}
diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py
index 686a186a7cbc..9a8cb203c055 100644
--- a/homeassistant/components/zwave_js/cover.py
+++ b/homeassistant/components/zwave_js/cover.py
@@ -4,13 +4,23 @@ from __future__ import annotations
from typing import Any, cast
from zwave_js_server.client import Client as ZwaveClient
-from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY
+from zwave_js_server.const import (
+ CURRENT_VALUE_PROPERTY,
+ TARGET_STATE_PROPERTY,
+ TARGET_VALUE_PROPERTY,
+)
from zwave_js_server.const.command_class.barrier_operator import BarrierState
from zwave_js_server.const.command_class.multilevel_switch import (
COVER_ON_PROPERTY,
COVER_OPEN_PROPERTY,
COVER_UP_PROPERTY,
)
+from zwave_js_server.const.command_class.window_covering import (
+ NO_POSITION_PROPERTY_KEYS,
+ NO_POSITION_SUFFIX,
+ WINDOW_COVERING_OPEN_PROPERTY,
+ SlatStates,
+)
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value as ZwaveValue
@@ -27,7 +37,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_CLIENT, DOMAIN
+from .const import (
+ COVER_POSITION_PROPERTY_KEYS,
+ COVER_TILT_PROPERTY_KEYS,
+ DATA_CLIENT,
+ DOMAIN,
+)
from .discovery import ZwaveDiscoveryInfo
from .discovery_data_template import CoverTiltDataTemplate
from .entity import ZWaveBaseEntity
@@ -49,12 +64,14 @@ async def async_setup_entry(
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
entities: list[ZWaveBaseEntity] = []
- if info.platform_hint == "motorized_barrier":
+ if info.platform_hint == "window_covering":
+ entities.append(ZWaveWindowCovering(config_entry, driver, info))
+ elif info.platform_hint == "motorized_barrier":
entities.append(ZwaveMotorizedBarrier(config_entry, driver, info))
elif info.platform_hint and info.platform_hint.endswith("tilt"):
entities.append(ZWaveTiltCover(config_entry, driver, info))
else:
- entities.append(ZWaveCover(config_entry, driver, info))
+ entities.append(ZWaveMultilevelSwitchCover(config_entry, driver, info))
async_add_entities(entities)
config_entry.async_on_unload(
@@ -66,44 +83,206 @@ async def async_setup_entry(
)
-def percent_to_zwave_position(value: int) -> int:
- """Convert position in 0-100 scale to 0-99 scale.
+class CoverPositionMixin(ZWaveBaseEntity, CoverEntity):
+ """Mix-in class for cover with position support."""
- `value` -- (int) Position byte value from 0-100.
- """
- if value > 0:
- return max(1, round((value / 100) * 99))
- return 0
+ _current_position_value: ZwaveValue | None = None
+ _target_position_value: ZwaveValue | None = None
+ _stop_position_value: ZwaveValue | None = None
+
+ def _set_position_values(
+ self,
+ current_value: ZwaveValue,
+ target_value: ZwaveValue | None = None,
+ stop_value: ZwaveValue | None = None,
+ ) -> None:
+ """Set values for position."""
+ self._attr_supported_features = (
+ (self._attr_supported_features or 0)
+ | CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.SET_POSITION
+ )
+ self._current_position_value = current_value
+ self._target_position_value = target_value or self.get_zwave_value(
+ TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key
+ )
+
+ if stop_value:
+ self._stop_position_value = stop_value
+ self._attr_supported_features |= CoverEntityFeature.STOP
+
+ def percent_to_zwave_position(self, value: int) -> int:
+ """Convert position in 0-100 scale to closed_value-open_value scale."""
+ return (
+ round(max(min(1, (value / 100)), 0) * self._position_range)
+ + self._fully_closed_position
+ )
+
+ def zwave_to_percent_position(self, value: int) -> int:
+ """Convert closed_value-open_value scale to position in 0-100 scale."""
+ return round(
+ ((value - self._fully_closed_position) / self._position_range) * 100
+ )
+
+ @property
+ def _fully_open_position(self) -> int:
+ """Return value that represents fully opened position."""
+ max_ = self.info.primary_value.metadata.max
+ return 99 if max_ is None else max_
+
+ @property
+ def _fully_closed_position(self) -> int:
+ """Return value that represents fully closed position."""
+ min_ = self.info.primary_value.metadata.min
+ return 0 if min_ is None else min_
+
+ @property
+ def _position_range(self) -> int:
+ """Return range between fully opened and fully closed position."""
+ return self._fully_open_position - self._fully_closed_position
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Return true if cover is closed."""
+ if not (value := self._current_position_value) or value.value is None:
+ return None
+ return bool(value.value == self._fully_closed_position)
+
+ @property
+ def current_cover_position(self) -> int | None:
+ """Return the current position of cover where 0 means closed and 100 is fully open."""
+ if (
+ self._current_position_value is None
+ or self._current_position_value.value is None
+ ):
+ # guard missing value
+ return None
+ return self.zwave_to_percent_position(self._current_position_value.value)
+
+ async def async_set_cover_position(self, **kwargs: Any) -> None:
+ """Move the cover to a specific position."""
+ assert self._target_position_value
+ await self._async_set_value(
+ self._target_position_value,
+ self.percent_to_zwave_position(kwargs[ATTR_POSITION]),
+ )
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ assert self._target_position_value
+ await self._async_set_value(
+ self._target_position_value, self._fully_open_position
+ )
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close cover."""
+ assert self._target_position_value
+ await self._async_set_value(
+ self._target_position_value, self._fully_closed_position
+ )
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop cover."""
+ assert self._stop_position_value
+ # Stop the cover, will stop regardless of the actual direction of travel.
+ await self._async_set_value(self._stop_position_value, False)
-def percent_to_zwave_tilt(value: int) -> int:
- """Convert position in 0-100 scale to 0-99 scale.
+class CoverTiltMixin(ZWaveBaseEntity, CoverEntity):
+ """Mix-in class for cover with tilt support."""
- `value` -- (int) Position byte value from 0-100.
- """
- if value > 0:
- return round((value / 100) * 99)
- return 0
+ _current_tilt_value: ZwaveValue | None = None
+ _target_tilt_value: ZwaveValue | None = None
+ _stop_tilt_value: ZwaveValue | None = None
+
+ def _set_tilt_values(
+ self,
+ current_value: ZwaveValue,
+ target_value: ZwaveValue | None = None,
+ stop_value: ZwaveValue | None = None,
+ ) -> None:
+ """Set values for tilt."""
+ self._attr_supported_features = (
+ (self._attr_supported_features or 0)
+ | CoverEntityFeature.OPEN_TILT
+ | CoverEntityFeature.CLOSE_TILT
+ | CoverEntityFeature.SET_TILT_POSITION
+ )
+ self._current_tilt_value = current_value
+ self._target_tilt_value = target_value or self.get_zwave_value(
+ TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key
+ )
+
+ if stop_value:
+ self._stop_tilt_value = stop_value
+ self._attr_supported_features |= CoverEntityFeature.STOP_TILT
+
+ def percent_to_zwave_tilt(self, value: int) -> int:
+ """Convert position in 0-100 scale to closed_value-open_value scale."""
+ return (
+ round(max(min(1, (value / 100)), 0) * self._tilt_range)
+ + self._fully_closed_tilt
+ )
+
+ def zwave_to_percent_tilt(self, value: int) -> int:
+ """Convert closed_value-open_value scale to position in 0-100 scale."""
+ return round(((value - self._fully_closed_tilt) / self._tilt_range) * 100)
+
+ @property
+ def _fully_open_tilt(self) -> int:
+ """Return value that represents fully opened tilt."""
+ max_ = self.info.primary_value.metadata.max
+ return 99 if max_ is None else max_
+
+ @property
+ def _fully_closed_tilt(self) -> int:
+ """Return value that represents fully closed tilt."""
+ min_ = self.info.primary_value.metadata.min
+ return 0 if min_ is None else min_
+
+ @property
+ def _tilt_range(self) -> int:
+ """Return range between fully opened and fully closed tilt."""
+ return self._fully_open_tilt - self._fully_closed_tilt
+
+ @property
+ def current_cover_tilt_position(self) -> int | None:
+ """Return current position of cover tilt.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ if (value := self._current_tilt_value) is None or value.value is None:
+ return None
+ return self.zwave_to_percent_tilt(int(value.value))
+
+ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
+ """Move the cover tilt to a specific position."""
+ assert self._target_tilt_value
+ await self._async_set_value(
+ self._target_tilt_value,
+ self.percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]),
+ )
+
+ async def async_open_cover_tilt(self, **kwargs: Any) -> None:
+ """Open the cover tilt."""
+ assert self._target_tilt_value
+ await self._async_set_value(self._target_tilt_value, self._fully_open_tilt)
+
+ async def async_close_cover_tilt(self, **kwargs: Any) -> None:
+ """Close the cover tilt."""
+ assert self._target_tilt_value
+ await self._async_set_value(self._target_tilt_value, self._fully_closed_tilt)
+
+ async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
+ """Stop the cover tilt."""
+ assert self._stop_tilt_value
+ # Stop the tilt, will stop regardless of the actual direction of travel.
+ await self._async_set_value(self._stop_tilt_value, False)
-def zwave_tilt_to_percent(value: int) -> int:
- """Convert 0-99 scale to position in 0-100 scale.
-
- `value` -- (int) Position byte value from 0-99.
- """
- if value > 0:
- return round((value / 99) * 100)
- return 0
-
-
-class ZWaveCover(ZWaveBaseEntity, CoverEntity):
- """Representation of a Z-Wave Cover device."""
-
- _attr_supported_features = (
- CoverEntityFeature.OPEN
- | CoverEntityFeature.CLOSE
- | CoverEntityFeature.SET_POSITION
- )
+class ZWaveMultilevelSwitchCover(CoverPositionMixin):
+ """Representation of a Z-Wave Cover that uses Multilevel Switch CC for position."""
def __init__(
self,
@@ -113,67 +292,26 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity):
) -> None:
"""Initialize a ZWaveCover entity."""
super().__init__(config_entry, driver, info)
-
- self._stop_cover_value = (
- self.get_zwave_value(COVER_OPEN_PROPERTY)
- or self.get_zwave_value(COVER_UP_PROPERTY)
- or self.get_zwave_value(COVER_ON_PROPERTY)
+ self._set_position_values(
+ self.info.primary_value,
+ stop_value=(
+ self.get_zwave_value(COVER_OPEN_PROPERTY)
+ or self.get_zwave_value(COVER_UP_PROPERTY)
+ or self.get_zwave_value(COVER_ON_PROPERTY)
+ ),
)
- if self._stop_cover_value:
- self._attr_supported_features |= CoverEntityFeature.STOP
-
# Entity class attributes
self._attr_device_class = CoverDeviceClass.WINDOW
if self.info.platform_hint and self.info.platform_hint.startswith("shutter"):
self._attr_device_class = CoverDeviceClass.SHUTTER
- if self.info.platform_hint and self.info.platform_hint.startswith("blind"):
+ elif self.info.platform_hint and self.info.platform_hint.startswith("blind"):
self._attr_device_class = CoverDeviceClass.BLIND
-
- @property
- def is_closed(self) -> bool | None:
- """Return true if cover is closed."""
- if self.info.primary_value.value is None:
- # guard missing value
- return None
- return bool(self.info.primary_value.value == 0)
-
- @property
- def current_cover_position(self) -> int | None:
- """Return the current position of cover where 0 means closed and 100 is fully open."""
- if self.info.primary_value.value is None:
- # guard missing value
- return None
- return round((cast(int, self.info.primary_value.value) / 99) * 100)
-
- async def async_set_cover_position(self, **kwargs: Any) -> None:
- """Move the cover to a specific position."""
- target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
- assert target_value is not None
- await self.info.node.async_set_value(
- target_value, percent_to_zwave_position(kwargs[ATTR_POSITION])
- )
-
- async def async_open_cover(self, **kwargs: Any) -> None:
- """Open the cover."""
- target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
- assert target_value is not None
- await self.info.node.async_set_value(target_value, 99)
-
- async def async_close_cover(self, **kwargs: Any) -> None:
- """Close cover."""
- target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
- assert target_value is not None
- await self.info.node.async_set_value(target_value, 0)
-
- async def async_stop_cover(self, **kwargs: Any) -> None:
- """Stop cover."""
- assert self._stop_cover_value
- # Stop the cover, will stop regardless of the actual direction of travel.
- await self.info.node.async_set_value(self._stop_cover_value, False)
+ elif self.info.platform_hint and self.info.platform_hint.startswith("gate"):
+ self._attr_device_class = CoverDeviceClass.GATE
-class ZWaveTiltCover(ZWaveCover):
+class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin):
"""Representation of a Z-Wave cover device with tilt."""
def __init__(
@@ -185,42 +323,84 @@ class ZWaveTiltCover(ZWaveCover):
"""Initialize a ZWaveCover entity."""
super().__init__(config_entry, driver, info)
- self._current_tilt_value = cast(
- CoverTiltDataTemplate, self.info.platform_data_template
- ).current_tilt_value(self.info.platform_data)
-
- self._attr_supported_features |= (
- CoverEntityFeature.OPEN_TILT
- | CoverEntityFeature.CLOSE_TILT
- | CoverEntityFeature.SET_TILT_POSITION
+ template = cast(CoverTiltDataTemplate, self.info.platform_data_template)
+ self._set_tilt_values(
+ template.current_tilt_value(self.info.platform_data),
+ template.target_tilt_value(self.info.platform_data),
)
+
+class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin):
+ """Representation of a Z-Wave Window Covering cover device."""
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize."""
+ super().__init__(config_entry, driver, info)
+ pos_value: ZwaveValue | None = None
+ tilt_value: ZwaveValue | None = None
+
+ # If primary value is for position, we have to search for a tilt value
+ if info.primary_value.property_key in COVER_POSITION_PROPERTY_KEYS:
+ pos_value = info.primary_value
+ tilt_value = next(
+ (
+ value
+ for property_key in COVER_TILT_PROPERTY_KEYS
+ if (
+ value := self.get_zwave_value(
+ CURRENT_VALUE_PROPERTY, value_property_key=property_key
+ )
+ )
+ ),
+ None,
+ )
+ # If primary value is for tilt, there is no position value
+ else:
+ tilt_value = info.primary_value
+
+ # Set position and tilt values if they exist. If the corresponding value is of
+ # the type No Position, we remove the corresponding set position feature.
+ for set_values_func, value, set_position_feature in (
+ (self._set_position_values, pos_value, CoverEntityFeature.SET_POSITION),
+ (self._set_tilt_values, tilt_value, CoverEntityFeature.SET_TILT_POSITION),
+ ):
+ if value:
+ set_values_func(
+ value,
+ stop_value=self.get_zwave_value(
+ WINDOW_COVERING_OPEN_PROPERTY,
+ value_property_key=value.property_key,
+ ),
+ )
+ if value.property_key in NO_POSITION_PROPERTY_KEYS:
+ assert self._attr_supported_features
+ self._attr_supported_features ^= set_position_feature
+
+ additional_info: list[str] = []
+ for value in (self._current_position_value, self._current_tilt_value):
+ if value and value.property_key_name:
+ additional_info.append(
+ value.property_key_name.removesuffix(f" {NO_POSITION_SUFFIX}")
+ )
+ self._attr_name = self.generate_name(additional_info=additional_info)
+ self._attr_device_class = CoverDeviceClass.WINDOW
+
@property
- def current_cover_tilt_position(self) -> int | None:
- """Return current position of cover tilt.
+ def _fully_open_tilt(self) -> int:
+ """Return position to open cover tilt."""
+ return SlatStates.OPEN
- None is unknown, 0 is closed, 100 is fully open.
- """
- value = self._current_tilt_value
- if value is None or value.value is None:
- return None
- return zwave_tilt_to_percent(int(value.value))
+ @property
+ def _fully_closed_tilt(self) -> int:
+ """Return position to close cover tilt."""
+ return SlatStates.CLOSED_1
- async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
- """Move the cover tilt to a specific position."""
- assert self._current_tilt_value
- await self.info.node.async_set_value(
- self._current_tilt_value,
- percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]),
- )
-
- async def async_open_cover_tilt(self, **kwargs: Any) -> None:
- """Open the cover tilt."""
- await self.async_set_cover_tilt_position(tilt_position=100)
-
- async def async_close_cover_tilt(self, **kwargs: Any) -> None:
- """Close the cover tilt."""
- await self.async_set_cover_tilt_position(tilt_position=0)
+ @property
+ def _tilt_range(self) -> int:
+ """Return range of valid tilt positions."""
+ return abs(SlatStates.CLOSED_2 - SlatStates.CLOSED_1)
class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity):
@@ -273,8 +453,8 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the garage door."""
- await self.info.node.async_set_value(self._target_state, BarrierState.OPEN)
+ await self._async_set_value(self._target_state, BarrierState.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the garage door."""
- await self.info.node.async_set_value(self._target_state, BarrierState.CLOSED)
+ await self._async_set_value(self._target_state, BarrierState.CLOSED)
diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py
index 3a585b44f586..20c37b5cbb6a 100644
--- a/homeassistant/components/zwave_js/device_action.py
+++ b/homeassistant/components/zwave_js/device_action.py
@@ -101,6 +101,7 @@ RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER,
+ vol.Required(ATTR_ENDPOINT): vol.Coerce(int),
vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str),
vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str),
vol.Required(ATTR_VALUE): vol.Coerce(int),
@@ -168,6 +169,7 @@ async def async_get_actions(
{
**base_action,
CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER,
+ ATTR_ENDPOINT: config_value.endpoint,
ATTR_CONFIG_PARAMETER: config_value.property_,
ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key,
CONF_SUBTYPE: generate_config_parameter_subtype(config_value),
@@ -186,8 +188,9 @@ async def async_get_actions(
# underlying value is not being monitored by HA so we shouldn't allow
# actions against it.
if (
- state := hass.states.get(entry.entity_id)
- ) and state.state == STATE_UNAVAILABLE:
+ not (state := hass.states.get(entry.entity_id))
+ or state.state == STATE_UNAVAILABLE
+ ):
continue
entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id}
actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE})
@@ -209,9 +212,7 @@ async def async_get_actions(
# If the value has the meterType CC specific value, we can add a reset_meter
# action for it
if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific:
- endpoint_idx = value.endpoint
- if endpoint_idx is None:
- endpoint_idx = 0
+ endpoint_idx = value.endpoint or 0
meter_endpoints[endpoint_idx].setdefault(
CONF_ENTITY_ID, entry.entity_id
)
@@ -348,6 +349,7 @@ async def async_get_action_capabilities(
CommandClass.CONFIGURATION,
config[ATTR_CONFIG_PARAMETER],
property_key=config[ATTR_CONFIG_PARAMETER_BITMASK],
+ endpoint=config[ATTR_ENDPOINT],
)
value_schema = get_config_parameter_value_schema(node, value_id)
if value_schema is None:
diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py
index 11c4fde31375..7a60d491b3cb 100644
--- a/homeassistant/components/zwave_js/device_automation_helpers.py
+++ b/homeassistant/components/zwave_js/device_automation_helpers.py
@@ -47,9 +47,15 @@ def generate_config_parameter_subtype(config_value: ConfigurationValue) -> str:
if config_value.property_key:
# Property keys for config values are always an int
assert isinstance(config_value.property_key, int)
- parameter = f"{parameter}[{hex(config_value.property_key)}]"
+ parameter = (
+ f"{parameter}[{hex(config_value.property_key)}] on endpoint "
+ f"{config_value.endpoint}"
+ )
- return f"{parameter} ({config_value.property_name})"
+ return (
+ f"{parameter} ({config_value.property_name}) on endpoint "
+ f"{config_value.endpoint}"
+ )
@callback
diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py
index bea2836fead7..c6aa14bceb68 100644
--- a/homeassistant/components/zwave_js/discovery.py
+++ b/homeassistant/components/zwave_js/discovery.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Generator
from dataclasses import asdict, dataclass, field
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
from awesomeversion import AwesomeVersion
from zwave_js_server.const import (
@@ -41,14 +41,18 @@ from zwave_js_server.const.command_class.thermostat import (
from zwave_js_server.exceptions import UnknownValueData
from zwave_js_server.model.device_class import DeviceClassItem
from zwave_js_server.model.node import Node as ZwaveNode
-from zwave_js_server.model.value import Value as ZwaveValue
+from zwave_js_server.model.value import (
+ ConfigurationValue,
+ ConfigurationValueType,
+ Value as ZwaveValue,
+)
from homeassistant.backports.enum import StrEnum
-from homeassistant.const import Platform
+from homeassistant.const import EntityCategory, Platform
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntry
-from .const import LOGGER
+from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, LOGGER
from .discovery_data_template import (
BaseDiscoverySchemaDataTemplate,
ConfigurableFanValueMappingDataTemplate,
@@ -108,7 +112,8 @@ class ZwaveDiscoveryInfo:
node: ZwaveNode
# the value object itself for primary value
primary_value: ZwaveValue
- # bool to specify whether state is assumed and events should be fired on value update
+ # bool to specify whether state is assumed and events should be fired on value
+ # update
assumed_state: bool
# the home assistant platform for which an entity should be created
platform: Platform
@@ -122,6 +127,8 @@ class ZwaveDiscoveryInfo:
platform_data_template: BaseDiscoverySchemaDataTemplate | None = None
# bool to specify whether entity should be enabled by default
entity_registry_enabled_default: bool = True
+ # the entity category for the discovered entity
+ entity_category: EntityCategory | None = None
@dataclass
@@ -143,10 +150,14 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne):
property_name: set[str] | None = None
# [optional] the value's property key must match ANY of these values
property_key: set[str | int | None] | None = None
- # [optional] the value's property key name must match ANY of these values
- property_key_name: set[str | None] | None = None
+ # [optional] the value's property key must NOT match ANY of these values
+ not_property_key: set[str | int | None] | None = None
# [optional] the value's metadata_type must match ANY of these values
type: set[str] | None = None
+ # [optional] the value's metadata_readable must match this value
+ readable: bool | None = None
+ # [optional] the value's metadata_writeable must match this value
+ writeable: bool | None = None
# [optional] the value's states map must include ANY of these key/value pairs
any_available_states: set[tuple[int, str]] | None = None
@@ -176,14 +187,10 @@ class ZWaveDiscoverySchema:
product_type: set[int] | None = None
# [optional] the node's firmware_version must be within this range
firmware_version_range: FirmwareVersionRange | None = None
- # [optional] the node's firmware_version must match ANY of these values
- firmware_version: set[str] | None = None
- # [optional] the node's basic device class must match ANY of these values
- device_class_basic: set[str | int] | None = None
# [optional] the node's generic device class must match ANY of these values
- device_class_generic: set[str | int] | None = None
+ device_class_generic: set[str] | None = None
# [optional] the node's specific device class must match ANY of these values
- device_class_specific: set[str | int] | None = None
+ device_class_specific: set[str] | None = None
# [optional] additional values that ALL need to be present
# on the node for this scheme to pass
required_values: list[ZWaveValueDiscoverySchema] | None = None
@@ -198,34 +205,8 @@ class ZWaveDiscoverySchema:
assumed_state: bool = False
# [optional] bool to specify whether entity should be enabled by default
entity_registry_enabled_default: bool = True
-
-
-def get_config_parameter_discovery_schema(
- property_: set[str | int] | None = None,
- property_name: set[str] | None = None,
- property_key: set[str | int | None] | None = None,
- property_key_name: set[str | None] | None = None,
- **kwargs: Any,
-) -> ZWaveDiscoverySchema:
- """Return a discovery schema for a config parameter.
-
- Supports all keyword arguments to ZWaveValueDiscoverySchema except platform, hint,
- and primary_value.
- """
- return ZWaveDiscoverySchema(
- platform=Platform.SENSOR,
- hint="config_parameter",
- primary_value=ZWaveValueDiscoverySchema(
- command_class={CommandClass.CONFIGURATION},
- property=property_,
- property_name=property_name,
- property_key=property_key,
- property_key_name=property_key_name,
- type={ValueType.NUMBER},
- ),
- entity_registry_enabled_default=False,
- **kwargs,
- )
+ # [optional] the entity category for the discovered entity
+ entity_category: EntityCategory | None = None
DOOR_LOCK_CURRENT_MODE_SCHEMA = ZWaveValueDiscoverySchema(
@@ -240,6 +221,12 @@ SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
type={ValueType.NUMBER},
)
+SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
+ command_class={CommandClass.SWITCH_MULTILEVEL},
+ property={TARGET_VALUE_PROPERTY},
+ type={ValueType.NUMBER},
+)
+
SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY}
)
@@ -250,6 +237,18 @@ SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema(
type={ValueType.NUMBER},
)
+WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
+ command_class={CommandClass.WINDOW_COVERING},
+ property={CURRENT_VALUE_PROPERTY},
+ property_key=COVER_POSITION_PROPERTY_KEYS,
+)
+
+WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
+ command_class={CommandClass.WINDOW_COVERING},
+ property={CURRENT_VALUE_PROPERTY},
+ property_key=COVER_TILT_PROPERTY_KEYS,
+)
+
# For device class mapping see:
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json
DISCOVERY_SCHEMAS = [
@@ -261,6 +260,7 @@ DISCOVERY_SCHEMAS = [
product_id={0x3131},
product_type={0x4944},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
+ required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA],
),
# GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002
ZWaveDiscoverySchema(
@@ -353,12 +353,18 @@ DISCOVERY_SCHEMAS = [
product_type={0x0301, 0x0302},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=CoverTiltDataTemplate(
- tilt_value_id=ZwaveValueID(
+ current_tilt_value_id=ZwaveValueID(
property_="fibaro",
command_class=CommandClass.MANUFACTURER_PROPRIETARY,
endpoint=0,
property_key="venetianBlindsTilt",
- )
+ ),
+ target_tilt_value_id=ZwaveValueID(
+ property_="fibaro",
+ command_class=CommandClass.MANUFACTURER_PROPRIETARY,
+ endpoint=0,
+ property_key="venetianBlindsTilt",
+ ),
),
required_values=[
ZWaveValueDiscoverySchema(
@@ -368,6 +374,16 @@ DISCOVERY_SCHEMAS = [
)
],
),
+ # Fibaro Nice BiDi-ZWave (IBT4ZWAVE)
+ ZWaveDiscoverySchema(
+ platform=Platform.COVER,
+ hint="gate",
+ manufacturer_id={0x0441},
+ product_id={0x1000},
+ product_type={0x2400},
+ primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
+ required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA],
+ ),
# Qubino flush shutter
ZWaveDiscoverySchema(
platform=Platform.COVER,
@@ -568,13 +584,6 @@ DISCOVERY_SCHEMAS = [
),
absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA],
),
- # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS =======
- # Door lock mode config parameter. Functionality equivalent to Notification CC
- # list sensors.
- get_config_parameter_discovery_schema(
- property_name={"Door lock mode"},
- device_class_generic={"Entry Control"},
- ),
# ====== START OF GENERIC MAPPING SCHEMAS =======
# locks
# Door Lock CC
@@ -696,6 +705,18 @@ DISCOVERY_SCHEMAS = [
),
allow_multi=True,
),
+ # binary sensor for Indicator CC
+ ZWaveDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ hint="boolean",
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={CommandClass.INDICATOR},
+ type={ValueType.BOOLEAN},
+ readable=True,
+ writeable=False,
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
# generic text sensors
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
@@ -705,24 +726,16 @@ DISCOVERY_SCHEMAS = [
type={ValueType.STRING},
),
),
- ZWaveDiscoverySchema(
- platform=Platform.SENSOR,
- hint="string_sensor",
- primary_value=ZWaveValueDiscoverySchema(
- command_class={CommandClass.INDICATOR},
- type={ValueType.STRING},
- ),
- entity_registry_enabled_default=False,
- ),
# generic numeric sensors
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
hint="numeric_sensor",
primary_value=ZWaveValueDiscoverySchema(
command_class={
- CommandClass.SENSOR_MULTILEVEL,
- CommandClass.SENSOR_ALARM,
CommandClass.BATTERY,
+ CommandClass.ENERGY_PRODUCTION,
+ CommandClass.SENSOR_ALARM,
+ CommandClass.SENSOR_MULTILEVEL,
},
type={ValueType.NUMBER},
),
@@ -734,9 +747,11 @@ DISCOVERY_SCHEMAS = [
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.INDICATOR},
type={ValueType.NUMBER},
+ readable=True,
+ writeable=False,
),
data_template=NumericSensorDataTemplate(),
- entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
# Meter sensors for Meter CC
ZWaveDiscoverySchema(
@@ -769,9 +784,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.NUMBER,
hint="Basic",
primary_value=ZWaveValueDiscoverySchema(
- command_class={
- CommandClass.BASIC,
- },
+ command_class={CommandClass.BASIC},
type={ValueType.NUMBER},
property={CURRENT_VALUE_PROPERTY},
),
@@ -784,14 +797,48 @@ DISCOVERY_SCHEMAS = [
property={TARGET_VALUE_PROPERTY},
)
],
- data_template=NumericSensorDataTemplate(),
entity_registry_enabled_default=False,
),
+ # number for Indicator CC (exclude property keys 3-5)
+ ZWaveDiscoverySchema(
+ platform=Platform.NUMBER,
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={CommandClass.INDICATOR},
+ type={ValueType.NUMBER},
+ not_property_key={3, 4, 5},
+ readable=True,
+ writeable=True,
+ ),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ # button for Indicator CC
+ ZWaveDiscoverySchema(
+ platform=Platform.BUTTON,
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={CommandClass.INDICATOR},
+ type={ValueType.BOOLEAN},
+ readable=False,
+ writeable=True,
+ ),
+ entity_category=EntityCategory.CONFIG,
+ ),
# binary switches
ZWaveDiscoverySchema(
platform=Platform.SWITCH,
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
),
+ # switch for Indicator CC
+ ZWaveDiscoverySchema(
+ platform=Platform.SWITCH,
+ hint="indicator",
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={CommandClass.INDICATOR},
+ type={ValueType.BOOLEAN},
+ readable=True,
+ writeable=True,
+ ),
+ entity_category=EntityCategory.CONFIG,
+ ),
# binary switch
# barrier operator signaling states
ZWaveDiscoverySchema(
@@ -807,7 +854,18 @@ DISCOVERY_SCHEMAS = [
# window coverings
ZWaveDiscoverySchema(
platform=Platform.COVER,
- hint="cover",
+ hint="window_covering",
+ primary_value=WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA,
+ ),
+ ZWaveDiscoverySchema(
+ platform=Platform.COVER,
+ hint="window_covering",
+ primary_value=WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA,
+ absent_values=[WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA],
+ ),
+ ZWaveDiscoverySchema(
+ platform=Platform.COVER,
+ hint="multilevel_switch",
device_class_generic={"Multilevel Switch"},
device_class_specific={
"Motor Control Class A",
@@ -816,6 +874,10 @@ DISCOVERY_SCHEMAS = [
"Multiposition Motor",
},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
+ absent_values=[
+ WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA,
+ WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA,
+ ],
),
# cover
# motorized barriers
@@ -842,6 +904,7 @@ DISCOVERY_SCHEMAS = [
device_class_generic={"Multilevel Switch"},
device_class_specific={"Fan Switch"},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
+ required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA],
),
# number platform
# valve control for thermostats
@@ -920,9 +983,8 @@ def async_discover_node_values(
"""Run discovery on ZWave node and return matching (primary) values."""
for value in node.values.values():
# We don't need to rediscover an already processed value_id
- if value.value_id in discovered_value_ids[device.id]:
- continue
- yield from async_discover_single_value(value, device, discovered_value_ids)
+ if value.value_id not in discovered_value_ids[device.id]:
+ yield from async_discover_single_value(value, device, discovered_value_ids)
@callback
@@ -932,24 +994,20 @@ def async_discover_single_value(
"""Run discovery on a single ZWave value and return matching schema info."""
discovered_value_ids[device.id].add(value.value_id)
for schema in DISCOVERY_SCHEMAS:
- # check manufacturer_id
+ # check manufacturer_id, product_id, product_type
if (
- schema.manufacturer_id is not None
- and value.node.manufacturer_id not in schema.manufacturer_id
- ):
- continue
-
- # check product_id
- if (
- schema.product_id is not None
- and value.node.product_id not in schema.product_id
- ):
- continue
-
- # check product_type
- if (
- schema.product_type is not None
- and value.node.product_type not in schema.product_type
+ (
+ schema.manufacturer_id is not None
+ and value.node.manufacturer_id not in schema.manufacturer_id
+ )
+ or (
+ schema.product_id is not None
+ and value.node.product_id not in schema.product_id
+ )
+ or (
+ schema.product_type is not None
+ and value.node.product_type not in schema.product_type
+ )
):
continue
@@ -968,19 +1026,6 @@ def async_discover_single_value(
):
continue
- # check firmware_version
- if (
- schema.firmware_version is not None
- and value.node.firmware_version not in schema.firmware_version
- ):
- continue
-
- # check device_class_basic
- if value.node.device_class and not check_device_class(
- value.node.device_class.basic, schema.device_class_basic
- ):
- continue
-
# check device_class_generic
if value.node.device_class and not check_device_class(
value.node.device_class.generic, schema.device_class_generic
@@ -1041,6 +1086,7 @@ def async_discover_single_value(
platform_data=resolved_data,
additional_value_ids_to_watch=additional_value_ids_to_watch,
entity_registry_enabled_default=schema.entity_registry_enabled_default,
+ entity_category=schema.entity_category,
)
if not schema.allow_multi:
@@ -1048,6 +1094,85 @@ def async_discover_single_value(
# by other schemas/platforms
return
+ if value.command_class == CommandClass.CONFIGURATION:
+ yield from async_discover_single_configuration_value(
+ cast(ConfigurationValue, value)
+ )
+
+
+@callback
+def async_discover_single_configuration_value(
+ value: ConfigurationValue,
+) -> Generator[ZwaveDiscoveryInfo, None, None]:
+ """Run discovery on a single ZWave configuration value and return matching schema info."""
+ if value.metadata.writeable and value.metadata.readable:
+ if value.configuration_value_type == ConfigurationValueType.ENUMERATED:
+ yield ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=False,
+ platform=Platform.SELECT,
+ platform_hint="config_parameter",
+ platform_data=None,
+ additional_value_ids_to_watch=set(),
+ entity_registry_enabled_default=False,
+ )
+ elif value.configuration_value_type in (
+ ConfigurationValueType.RANGE,
+ ConfigurationValueType.MANUAL_ENTRY,
+ ):
+ if value.metadata.type == ValueType.BOOLEAN or (
+ value.metadata.min == 0 and value.metadata.max == 1
+ ):
+ yield ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=False,
+ platform=Platform.SWITCH,
+ platform_hint="config_parameter",
+ platform_data=None,
+ additional_value_ids_to_watch=set(),
+ entity_registry_enabled_default=False,
+ )
+ else:
+ yield ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=False,
+ platform=Platform.NUMBER,
+ platform_hint="config_parameter",
+ platform_data=None,
+ additional_value_ids_to_watch=set(),
+ entity_registry_enabled_default=False,
+ )
+ elif not value.metadata.writeable and value.metadata.readable:
+ if value.metadata.type == ValueType.BOOLEAN or (
+ value.metadata.min == 0
+ and value.metadata.max == 1
+ and not value.metadata.states
+ ):
+ yield ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=False,
+ platform=Platform.BINARY_SENSOR,
+ platform_hint="config_parameter",
+ platform_data=None,
+ additional_value_ids_to_watch=set(),
+ entity_registry_enabled_default=False,
+ )
+ else:
+ yield ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=False,
+ platform=Platform.SENSOR,
+ platform_hint="config_parameter",
+ platform_data=None,
+ additional_value_ids_to_watch=set(),
+ entity_registry_enabled_default=False,
+ )
+
@callback
def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
@@ -1076,15 +1201,21 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
and value.property_key not in schema.property_key
):
return False
- # check property_key_name
+ # check property_key against not_property_key set
if (
- schema.property_key_name is not None
- and value.property_key_name not in schema.property_key_name
+ schema.not_property_key is not None
+ and value.property_key in schema.not_property_key
):
return False
# check metadata_type
if schema.type is not None and value.metadata.type not in schema.type:
return False
+ # check metadata_readable
+ if schema.readable is not None and value.metadata.readable != schema.readable:
+ return False
+ # check metadata_writeable
+ if schema.writeable is not None and value.metadata.writeable != schema.writeable:
+ return False
# check available states
if (
schema.any_available_states is not None
@@ -1100,14 +1231,11 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
@callback
def check_device_class(
- device_class: DeviceClassItem, required_value: set[str | int] | None
+ device_class: DeviceClassItem, required_value: set[str] | None
) -> bool:
"""Check if device class id or label matches."""
if required_value is None:
return True
- for val in required_value:
- if isinstance(val, str) and device_class.label == val:
- return True
- if isinstance(val, int) and device_class.key == val:
- return True
+ if any(device_class.label == val for val in required_value):
+ return True
return False
diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py
index 249a175d4d29..7a274df41f24 100644
--- a/homeassistant/components/zwave_js/discovery_data_template.py
+++ b/homeassistant/components/zwave_js/discovery_data_template.py
@@ -7,6 +7,14 @@ import logging
from typing import Any, cast
from zwave_js_server.const import CommandClass
+from zwave_js_server.const.command_class.energy_production import (
+ EnergyProductionParameter,
+ EnergyProductionScaleType,
+ PowerScale,
+ TodaysProductionScale,
+ TotalProductionScale,
+ TotalTimeScale,
+)
from zwave_js_server.const.command_class.meter import (
CURRENT_METER_TYPES,
ENERGY_TOTAL_INCREASING_METER_TYPES,
@@ -85,6 +93,10 @@ from zwave_js_server.model.value import (
Value as ZwaveValue,
get_value_id_str,
)
+from zwave_js_server.util.command_class.energy_production import (
+ get_energy_production_parameter,
+ get_energy_production_scale_type,
+)
from zwave_js_server.util.command_class.meter import get_meter_scale_type
from zwave_js_server.util.command_class.multilevel_sensor import (
get_multilevel_sensor_scale_type,
@@ -123,6 +135,10 @@ from .const import (
ENTITY_DESC_KEY_CO2,
ENTITY_DESC_KEY_CURRENT,
ENTITY_DESC_KEY_ENERGY_MEASUREMENT,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL,
ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING,
ENTITY_DESC_KEY_HUMIDITY,
ENTITY_DESC_KEY_ILLUMINANCE,
@@ -134,10 +150,23 @@ from .const import (
ENTITY_DESC_KEY_TARGET_TEMPERATURE,
ENTITY_DESC_KEY_TEMPERATURE,
ENTITY_DESC_KEY_TOTAL_INCREASING,
+ ENTITY_DESC_KEY_UV_INDEX,
ENTITY_DESC_KEY_VOLTAGE,
)
from .helpers import ZwaveValueID
+ENERGY_PRODUCTION_DEVICE_CLASS_MAP: dict[str, list[EnergyProductionParameter]] = {
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME: [EnergyProductionParameter.TOTAL_TIME],
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY: [
+ EnergyProductionParameter.TODAYS_PRODUCTION
+ ],
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL: [
+ EnergyProductionParameter.TOTAL_PRODUCTION
+ ],
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER: [EnergyProductionParameter.POWER],
+}
+
+
METER_DEVICE_CLASS_MAP: dict[str, list[MeterScaleType]] = {
ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES,
ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES,
@@ -158,6 +187,17 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, list[MultilevelSensorType]] = {
ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS,
ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS,
ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS,
+ ENTITY_DESC_KEY_UV_INDEX: [MultilevelSensorType.ULTRAVIOLET],
+}
+
+ENERGY_PRODUCTION_UNIT_MAP: dict[str, list[EnergyProductionScaleType]] = {
+ UnitOfEnergy.WATT_HOUR: [
+ TotalProductionScale.WATT_HOURS,
+ TodaysProductionScale.WATT_HOURS,
+ ],
+ UnitOfPower.WATT: [PowerScale.WATTS],
+ UnitOfTime.SECONDS: [TotalTimeScale.SECONDS],
+ UnitOfTime.HOURS: [TotalTimeScale.HOURS],
}
METER_UNIT_MAP: dict[str, list[MeterScaleType]] = {
@@ -320,12 +360,18 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate):
@staticmethod
def find_key_from_matching_set(
- enum_value: MultilevelSensorType | MultilevelSensorScaleType | MeterScaleType,
+ enum_value: MultilevelSensorType
+ | MultilevelSensorScaleType
+ | MeterScaleType
+ | EnergyProductionParameter
+ | EnergyProductionScaleType,
set_map: Mapping[
str,
list[MultilevelSensorType]
| list[MultilevelSensorScaleType]
- | list[MeterScaleType],
+ | list[MeterScaleType]
+ | list[EnergyProductionScaleType]
+ | list[EnergyProductionParameter],
],
) -> str | None:
"""Find a key in a set map that matches a given enum value."""
@@ -387,36 +433,63 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate):
if key:
return NumericSensorDataTemplateData(key, unit)
+ if value.command_class == CommandClass.ENERGY_PRODUCTION:
+ energy_production_parameter = get_energy_production_parameter(value)
+ energy_production_scale_type = get_energy_production_scale_type(value)
+ unit = self.find_key_from_matching_set(
+ energy_production_scale_type, ENERGY_PRODUCTION_UNIT_MAP
+ )
+ key = self.find_key_from_matching_set(
+ energy_production_parameter, ENERGY_PRODUCTION_DEVICE_CLASS_MAP
+ )
+ if key:
+ return NumericSensorDataTemplateData(key, unit)
+
return NumericSensorDataTemplateData()
@dataclass
class TiltValueMix:
- """Mixin data class for the tilt_value."""
+ """Mixin data class for the current_tilt_value and target_tilt_value."""
- tilt_value_id: ZwaveValueID
+ current_tilt_value_id: ZwaveValueID
+ target_tilt_value_id: ZwaveValueID
@dataclass
class CoverTiltDataTemplate(BaseDiscoverySchemaDataTemplate, TiltValueMix):
"""Tilt data template class for Z-Wave Cover entities."""
- def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue | None]:
+ def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue]:
"""Resolve helper class data for a discovered value."""
- return {"tilt_value": self._get_value_from_id(value.node, self.tilt_value_id)}
+ current_tilt_value = self._get_value_from_id(
+ value.node, self.current_tilt_value_id
+ )
+ assert current_tilt_value
+ target_tilt_value = self._get_value_from_id(
+ value.node, self.target_tilt_value_id
+ )
+ assert target_tilt_value
+ return {
+ "current_tilt_value": current_tilt_value,
+ "target_tilt_value": target_tilt_value,
+ }
def values_to_watch(
self, resolved_data: dict[str, Any]
) -> Iterable[ZwaveValue | None]:
"""Return list of all ZwaveValues resolved by helper that should be watched."""
- return [resolved_data["tilt_value"]]
+ return [resolved_data["current_tilt_value"], resolved_data["target_tilt_value"]]
@staticmethod
- def current_tilt_value(
- resolved_data: dict[str, ZwaveValue | None]
- ) -> ZwaveValue | None:
+ def current_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue:
"""Get current tilt ZwaveValue from resolved data."""
- return resolved_data["tilt_value"]
+ return resolved_data["current_tilt_value"]
+
+ @staticmethod
+ def target_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue:
+ """Get target tilt ZwaveValue from resolved data."""
+ return resolved_data["target_tilt_value"]
@dataclass
diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py
index 2a57257ffbfb..ba086b3e8bfd 100644
--- a/homeassistant/components/zwave_js/entity.py
+++ b/homeassistant/components/zwave_js/entity.py
@@ -1,12 +1,17 @@
"""Generic Z-Wave Entity Class."""
from __future__ import annotations
+from collections.abc import Sequence
+from typing import Any
+
from zwave_js_server.const import NodeStatus
+from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity
@@ -44,10 +49,12 @@ class ZWaveBaseEntity(Entity):
# Entity class attributes
self._attr_name = self.generate_name()
self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id)
- self._attr_entity_registry_enabled_default = (
- self.info.entity_registry_enabled_default
- )
- self._attr_assumed_state = self.info.assumed_state
+ if self.info.entity_registry_enabled_default is False:
+ self._attr_entity_registry_enabled_default = False
+ if self.info.entity_category is not None:
+ self._attr_entity_category = self.info.entity_category
+ if self.info.assumed_state:
+ self._attr_assumed_state = True
# device is precreated in main handler
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, self.info.node)},
@@ -60,12 +67,20 @@ class ZWaveBaseEntity(Entity):
To be overridden by platforms needing this event.
"""
+ async def _async_poll_value(self, value_or_id: str | ZwaveValue) -> None:
+ """Poll a value."""
+ # We log an error instead of raising an exception because this service call occurs
+ # in a separate task and we don't want to raise the exception in that separate task
+ # because it is confusing to the user.
+ try:
+ await self.info.node.async_poll_value(value_or_id)
+ except BaseZwaveJSServerError as err:
+ LOGGER.error("Error while refreshing value %s: %s", value_or_id, err)
+
async def async_poll_value(self, refresh_all_values: bool) -> None:
"""Poll a value."""
if not refresh_all_values:
- self.hass.async_create_task(
- self.info.node.async_poll_value(self.info.primary_value)
- )
+ await self._async_poll_value(self.info.primary_value)
LOGGER.info(
(
"Refreshing primary value %s for %s, "
@@ -77,7 +92,7 @@ class ZWaveBaseEntity(Entity):
return
for value_id in self.watched_value_ids:
- self.hass.async_create_task(self.info.node.async_poll_value(value_id))
+ await self._async_poll_value(value_id)
LOGGER.info(
(
@@ -103,7 +118,18 @@ class ZWaveBaseEntity(Entity):
(
f"{DOMAIN}_"
f"{get_valueless_base_unique_id(self.driver, self.info.node)}_"
- "remove_entity_on_ready_node"
+ "remove_entity"
+ ),
+ self.async_remove,
+ )
+ )
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ (
+ f"{DOMAIN}_"
+ f"{get_valueless_base_unique_id(self.driver, self.info.node)}_"
+ "remove_entity_on_interview_started"
),
self.async_remove,
)
@@ -126,7 +152,7 @@ class ZWaveBaseEntity(Entity):
self,
include_value_name: bool = False,
alternate_value_name: str | None = None,
- additional_info: list[str] | None = None,
+ additional_info: Sequence[str | None] | None = None,
name_prefix: str | None = None,
) -> str:
"""Generate entity name."""
@@ -151,8 +177,11 @@ class ZWaveBaseEntity(Entity):
or self.info.primary_value.property_name
or ""
)
+
name = f"{name} {value_name}".strip()
- name = f"{name} {' '.join(additional_info or [])}".strip()
+ # Only include non empty additional info
+ if additional_info := [item for item in (additional_info or []) if item]:
+ name = f"{name} {' '.join(additional_info)}"
# append endpoint if > 1
if (
self.info.primary_value.endpoint is not None
@@ -274,3 +303,19 @@ class ZWaveBaseEntity(Entity):
):
self.watched_value_ids.add(return_value.value_id)
return return_value
+
+ async def _async_set_value(
+ self,
+ value: ZwaveValue,
+ new_value: Any,
+ options: dict | None = None,
+ wait_for_result: bool | None = None,
+ ) -> bool | None:
+ """Set value on node."""
+ try:
+ return await self.info.node.async_set_value(
+ value, new_value, options=options, wait_for_result=wait_for_result
+ )
+ except BaseZwaveJSServerError as err:
+ LOGGER.error("Unable to set value %s: %s", value.value_id, err)
+ raise HomeAssistantError from err
diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py
index 224921b1b785..d06306497657 100644
--- a/homeassistant/components/zwave_js/fan.py
+++ b/homeassistant/components/zwave_js/fan.py
@@ -6,6 +6,7 @@ from typing import Any, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass
+from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE
from zwave_js_server.const.command_class.thermostat import (
THERMOSTAT_FAN_OFF_PROPERTY,
THERMOSTAT_FAN_STATE_PROPERTY,
@@ -25,7 +26,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
- int_states_in_range,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
@@ -85,7 +85,11 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
) -> None:
"""Initialize the fan."""
super().__init__(config_entry, driver, info)
- self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
+ target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
+ assert target_value
+ self._target_value = target_value
+
+ self._use_optimistic_state: bool = False
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
@@ -96,9 +100,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage)
)
- if (target_value := self._target_value) is None:
- raise HomeAssistantError("Missing target value on device.")
- await self.info.node.async_set_value(target_value, zwave_speed)
+ await self._async_set_value(self._target_value, zwave_speed)
async def async_turn_on(
self,
@@ -112,20 +114,28 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
elif preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
else:
- if (target_value := self._target_value) is None:
- raise HomeAssistantError("Missing target value on device.")
- # Value 255 tells device to return to previous value
- await self.info.node.async_set_value(target_value, 255)
+ if self.info.primary_value.command_class != CommandClass.SWITCH_MULTILEVEL:
+ raise HomeAssistantError(
+ "`percentage` or `preset_mode` must be provided"
+ )
+ # If this is a Multilevel Switch CC value, we do an optimistic state update
+ # when setting to a previous value to avoid waiting for the value to be
+ # updated from the device which is typically delayed and causes a confusing
+ # UX.
+ await self._async_set_value(self._target_value, SET_TO_PREVIOUS_VALUE)
+ self._use_optimistic_state = True
+ self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- if (target_value := self._target_value) is None:
- raise HomeAssistantError("Missing target value on device.")
- await self.info.node.async_set_value(target_value, 0)
+ await self._async_set_value(self._target_value, 0)
@property
def is_on(self) -> bool | None:
"""Return true if device is on (speed above 0)."""
+ if self._use_optimistic_state:
+ self._use_optimistic_state = False
+ return True
if self.info.primary_value.value is None:
# guard missing value
return None
@@ -146,11 +156,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
"""Return the step size for percentage."""
return 1
- @property
- def speed_count(self) -> int:
- """Return the number of speeds the fan supports."""
- return int_states_in_range(DEFAULT_SPEED_RANGE)
-
class ValueMappingZwaveFan(ZwaveFan):
"""A Zwave fan with a value mapping data (e.g., 1-24 is low)."""
@@ -166,18 +171,14 @@ class ValueMappingZwaveFan(ZwaveFan):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
- if (target_value := self._target_value) is None:
- raise HomeAssistantError("Missing target value on device.")
zwave_speed = self.percentage_to_zwave_speed(percentage)
- await self.info.node.async_set_value(target_value, zwave_speed)
+ await self._async_set_value(self._target_value, zwave_speed)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
- if (target_value := self._target_value) is None:
- raise HomeAssistantError("Missing target value on device.")
for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items():
if preset_mode == mapped_preset_mode:
- await self.info.node.async_set_value(target_value, zwave_value)
+ await self._async_set_value(self._target_value, zwave_value)
return
raise NotValidPresetModeError(
@@ -277,12 +278,9 @@ class ValueMappingZwaveFan(ZwaveFan):
assert step_percentage
if percentage <= step_percentage:
- return max_speed
+ break
- # This shouldn't actually happen; the last entry in
- # `self.fan_value_mapping.speeds` should map to 100%.
- (_, last_max_speed) = self.fan_value_mapping.speeds[-1]
- return last_max_speed
+ return max_speed
def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None:
"""Convert a Zwave speed to a percentage.
@@ -342,13 +340,13 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity):
"""Turn the device on."""
if not self._fan_off:
raise HomeAssistantError("Unhandled action turn_on")
- await self.info.node.async_set_value(self._fan_off, False)
+ await self._async_set_value(self._fan_off, False)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
if not self._fan_off:
raise HomeAssistantError("Unhandled action turn_off")
- await self.info.node.async_set_value(self._fan_off, True)
+ await self._async_set_value(self._fan_off, True)
@property
def is_on(self) -> bool | None:
@@ -377,7 +375,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity):
except StopIteration:
raise ValueError(f"Received an invalid fan mode: {preset_mode}") from None
- await self.info.node.async_set_value(self._fan_mode, new_state)
+ await self._async_set_value(self._fan_mode, new_state)
@property
def preset_modes(self) -> list[str] | None:
diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py
index 80760930c2e7..02c6abbc732a 100644
--- a/homeassistant/components/zwave_js/humidifier.py
+++ b/homeassistant/components/zwave_js/humidifier.py
@@ -175,7 +175,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity):
else:
return
- await self.info.node.async_set_value(self._current_mode, new_mode)
+ await self._async_set_value(self._current_mode, new_mode)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off device."""
@@ -192,7 +192,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity):
else:
return
- await self.info.node.async_set_value(self._current_mode, new_mode)
+ await self._async_set_value(self._current_mode, new_mode)
@property
def target_humidity(self) -> int | None:
@@ -204,7 +204,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity):
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if self._setpoint:
- await self.info.node.async_set_value(self._setpoint, humidity)
+ await self._async_set_value(self._setpoint, humidity)
@property
def min_humidity(self) -> int:
diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py
index 29654503f001..1a9abb9b0f83 100644
--- a/homeassistant/components/zwave_js/light.py
+++ b/homeassistant/components/zwave_js/light.py
@@ -22,6 +22,7 @@ from zwave_js_server.const.command_class.color_switch import (
TARGET_COLOR_PROPERTY,
ColorComponent,
)
+from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value
@@ -164,6 +165,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if self.supports_brightness_transition or self.supports_color_transition:
self._attr_supported_features |= LightEntityFeature.TRANSITION
+ self._set_optimistic_state: bool = False
+
@callback
def on_value_update(self) -> None:
"""Call when a watched value is added or updated."""
@@ -187,10 +190,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
@property
def is_on(self) -> bool | None:
"""Return true if device is on (brightness above 0)."""
+ if self._set_optimistic_state:
+ self._set_optimistic_state = False
+ return True
brightness = self.brightness
- if brightness is None:
- return None
- return brightness > 0
+ return brightness > 0 if brightness is not None else None
@property
def hs_color(self) -> tuple[float, float] | None:
@@ -320,9 +324,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
color_name = MULTI_COLOR_MAP[color]
colors_dict[color_name] = value
# set updated color object
- await self.info.node.async_set_value(
- combined_color_val, colors_dict, zwave_transition
- )
+ await self._async_set_value(combined_color_val, colors_dict, zwave_transition)
async def _async_set_brightness(
self, brightness: int | None, transition: float | None = None
@@ -332,8 +334,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if not self._target_brightness:
return
if brightness is None:
- # Level 255 means to set it to previous value.
- zwave_brightness = 255
+ zwave_brightness = SET_TO_PREVIOUS_VALUE
else:
# Zwave multilevel switches use a range of [0, 99] to control brightness.
zwave_brightness = byte_to_zwave_brightness(brightness)
@@ -347,9 +348,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
zwave_transition = {TRANSITION_DURATION_OPTION: "default"}
# setting a value requires setting targetValue
- await self.info.node.async_set_value(
+ await self._async_set_value(
self._target_brightness, zwave_brightness, zwave_transition
)
+ # We do an optimistic state update when setting to a previous value
+ # to avoid waiting for the value to be updated from the device which is
+ # typically delayed and causes a confusing UX.
+ if (
+ zwave_brightness == SET_TO_PREVIOUS_VALUE
+ and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL
+ ):
+ self._set_optimistic_state = True
+ self.async_write_ha_state()
@callback
def _calculate_color_values(self) -> None:
@@ -444,8 +454,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
class ZwaveBlackIsOffLight(ZwaveLight):
"""Representation of a Z-Wave light where setting the color to black turns it off.
- Currently only supports lights with RGB, no color temperature,
- and no white channels.
+ Currently only supports lights with RGB, no color temperature, and no white
+ channels.
"""
def __init__(
@@ -471,13 +481,12 @@ class ZwaveBlackIsOffLight(ZwaveLight):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
- await super().async_turn_on(**kwargs)
-
if (
kwargs.get(ATTR_RGBW_COLOR) is not None
or kwargs.get(ATTR_COLOR_TEMP) is not None
or kwargs.get(ATTR_HS_COLOR) is not None
):
+ await super().async_turn_on(**kwargs)
return
transition = kwargs.get(ATTR_TRANSITION)
diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py
index efeadb9b6b33..5457916a1e13 100644
--- a/homeassistant/components/zwave_js/lock.py
+++ b/homeassistant/components/zwave_js/lock.py
@@ -13,12 +13,14 @@ from zwave_js_server.const.command_class.lock import (
LOCK_CMD_CLASS_TO_PROPERTY_MAP,
DoorLockMode,
)
+from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.util.lock import clear_usercode, set_usercode
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -97,14 +99,17 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity):
@property
def is_locked(self) -> bool | None:
"""Return true if the lock is locked."""
- if self.info.primary_value.value is None:
+ value = self.info.primary_value
+ if value.value is None or (
+ value.command_class == CommandClass.DOOR_LOCK
+ and value.value == DoorLockMode.UNKNOWN
+ ):
# guard missing value
return None
- return int(
- LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[
- CommandClass(self.info.primary_value.command_class)
- ]
- ) == int(self.info.primary_value.value)
+ return (
+ LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[CommandClass(value.command_class)]
+ == self.info.primary_value.value
+ )
async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None:
"""Set the lock state."""
@@ -114,7 +119,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity):
]
)
if target_value is not None:
- await self.info.node.async_set_value(
+ await self._async_set_value(
target_value,
STATE_TO_ZWAVE_MAP[self.info.primary_value.command_class][target_state],
)
@@ -129,10 +134,20 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity):
async def async_set_lock_usercode(self, code_slot: int, usercode: str) -> None:
"""Set the usercode to index X on the lock."""
- await set_usercode(self.info.node, code_slot, usercode)
+ try:
+ await set_usercode(self.info.node, code_slot, usercode)
+ except BaseZwaveJSServerError as err:
+ raise HomeAssistantError(
+ f"Unable to set lock usercode on code_slot {code_slot}: {err}"
+ ) from err
LOGGER.debug("User code at slot %s set", code_slot)
async def async_clear_lock_usercode(self, code_slot: int) -> None:
"""Clear the usercode at index X on the lock."""
- await clear_usercode(self.info.node, code_slot)
+ try:
+ await clear_usercode(self.info.node, code_slot)
+ except BaseZwaveJSServerError as err:
+ raise HomeAssistantError(
+ f"Unable to clear lock usercode on code_slot {code_slot}: {err}"
+ ) from err
LOGGER.debug("User code at slot %s cleared", code_slot)
diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json
index da144c398ed5..b163ace1d24f 100644
--- a/homeassistant/components/zwave_js/manifest.json
+++ b/homeassistant/components/zwave_js/manifest.json
@@ -8,7 +8,8 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
- "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.1"],
+ "quality_scale": "platinum",
+ "requirements": ["pyserial==3.5", "zwave-js-server-python==0.49.0"],
"usb": [
{
"vid": "0658",
diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py
index a6a219fa7c63..6aa4a57ea4c7 100644
--- a/homeassistant/components/zwave_js/number.py
+++ b/homeassistant/components/zwave_js/number.py
@@ -1,7 +1,8 @@
"""Support for Z-Wave controls using the number platform."""
from __future__ import annotations
-from typing import cast
+from collections.abc import Mapping
+from typing import Any, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import TARGET_VALUE_PROPERTY
@@ -10,12 +11,13 @@ from zwave_js_server.model.value import Value
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_CLIENT, DOMAIN
+from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
@@ -38,6 +40,10 @@ async def async_setup_entry(
entities: list[ZWaveBaseEntity] = []
if info.platform_hint == "volume":
entities.append(ZwaveVolumeNumberEntity(config_entry, driver, info))
+ elif info.platform_hint == "config_parameter":
+ entities.append(
+ ZWaveConfigParameterNumberEntity(config_entry, driver, info)
+ )
else:
entities.append(ZwaveNumberEntity(config_entry, driver, info))
async_add_entities(entities)
@@ -66,7 +72,9 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity):
self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY)
# Entity class attributes
- self._attr_name = self.generate_name(alternate_value_name=info.platform_hint)
+ self._attr_name = self.generate_name(
+ include_value_name=True, alternate_value_name=info.platform_hint
+ )
@property
def native_min_value(self) -> float:
@@ -96,7 +104,37 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity):
"""Set new value."""
if (target_value := self._target_value) is None:
raise HomeAssistantError("Missing target value on device.")
- await self.info.node.async_set_value(target_value, value)
+ await self._async_set_value(target_value, value)
+
+
+class ZWaveConfigParameterNumberEntity(ZwaveNumberEntity):
+ """Representation of a Z-Wave config parameter number."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize a ZWaveConfigParameterNumber entity."""
+ super().__init__(config_entry, driver, info)
+
+ property_key_name = self.info.primary_value.property_key_name
+ # Entity class attributes
+ self._attr_name = self.generate_name(
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[property_key_name] if property_key_name else None,
+ )
+
+ @property
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ """Return extra state attributes for entity."""
+ if not self.info.primary_value.metadata.states:
+ return None
+ return {
+ ATTR_RESERVED_VALUES: {
+ int(k): v for k, v in self.info.primary_value.metadata.states.items()
+ }
+ }
class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity):
@@ -126,6 +164,6 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
- await self.info.node.async_set_value(
+ await self._async_set_value(
self.info.primary_value, round(value * self.correction_factor)
)
diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py
index f74c98117b49..3956004336a0 100644
--- a/homeassistant/components/zwave_js/select.py
+++ b/homeassistant/components/zwave_js/select.py
@@ -42,6 +42,10 @@ async def async_setup_entry(
entities.append(
ZwaveMultilevelSwitchSelectEntity(config_entry, driver, info)
)
+ elif info.platform_hint == "config_parameter":
+ entities.append(
+ ZWaveConfigParameterSelectEntity(config_entry, driver, info)
+ )
else:
entities.append(ZwaveSelectEntity(config_entry, driver, info))
async_add_entities(entities)
@@ -88,7 +92,26 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity):
for key, val in self.info.primary_value.metadata.states.items()
if val == option
)
- await self.info.node.async_set_value(self.info.primary_value, int(key))
+ await self._async_set_value(self.info.primary_value, int(key))
+
+
+class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity):
+ """Representation of a Z-Wave config parameter select."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize a ZWaveConfigParameterSelect entity."""
+ super().__init__(config_entry, driver, info)
+
+ property_key_name = self.info.primary_value.property_key_name
+ # Entity class attributes
+ self._attr_name = self.generate_name(
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[property_key_name] if property_key_name else None,
+ )
class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity):
@@ -139,7 +162,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity):
for key, val in self._tones_value.metadata.states.items()
if val == option
)
- await self.info.node.async_set_value(self.info.primary_value, int(key))
+ await self._async_set_value(self.info.primary_value, int(key))
class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity):
@@ -174,4 +197,4 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity):
"""Change the selected option."""
assert self._target_value is not None
key = next(key for key, val in self._lookup_map.items() if val == option)
- await self.info.node.async_set_value(self._target_value, int(key))
+ await self._async_set_value(self._target_value, int(key))
diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py
index 7a0532f00eb4..316c0b81eeb3 100644
--- a/homeassistant/components/zwave_js/sensor.py
+++ b/homeassistant/components/zwave_js/sensor.py
@@ -6,14 +6,18 @@ from typing import cast
import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient
-from zwave_js_server.const import CommandClass, ConfigurationValueType, NodeStatus
+from zwave_js_server.const import CommandClass, NodeStatus
from zwave_js_server.const.command_class.meter import (
RESET_METER_OPTION_TARGET_VALUE,
RESET_METER_OPTION_TYPE,
)
+from zwave_js_server.exceptions import BaseZwaveJSServerError
+from zwave_js_server.model.controller import Controller
+from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node as ZwaveNode
-from zwave_js_server.model.value import ConfigurationValue
+from zwave_js_server.model.node.statistics import NodeStatisticsDataType
+from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType
from zwave_js_server.util.command_class.meter import get_meter_type
from homeassistant.components.sensor import (
@@ -29,6 +33,7 @@ from homeassistant.const import (
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ UV_INDEX,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -36,12 +41,14 @@ from homeassistant.const import (
UnitOfPower,
UnitOfPressure,
UnitOfTemperature,
+ UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
from .const import (
ATTR_METER_TYPE,
@@ -54,6 +61,10 @@ from .const import (
ENTITY_DESC_KEY_CO2,
ENTITY_DESC_KEY_CURRENT,
ENTITY_DESC_KEY_ENERGY_MEASUREMENT,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY,
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL,
ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING,
ENTITY_DESC_KEY_HUMIDITY,
ENTITY_DESC_KEY_ILLUMINANCE,
@@ -65,6 +76,7 @@ from .const import (
ENTITY_DESC_KEY_TARGET_TEMPERATURE,
ENTITY_DESC_KEY_TEMPERATURE,
ENTITY_DESC_KEY_TOTAL_INCREASING,
+ ENTITY_DESC_KEY_UV_INDEX,
ENTITY_DESC_KEY_VOLTAGE,
LOGGER,
SERVICE_RESET_METER,
@@ -231,6 +243,50 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
),
+ (
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME,
+ UnitOfTime.SECONDS,
+ ): SensorEntityDescription(
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME,
+ name="Energy production time",
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ ),
+ (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription(
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ ),
+ (
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY,
+ UnitOfEnergy.WATT_HOUR,
+ ): SensorEntityDescription(
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY,
+ name="Energy production today",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ ),
+ (
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL,
+ UnitOfEnergy.WATT_HOUR,
+ ): SensorEntityDescription(
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL,
+ name="Energy production total",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ ),
+ (
+ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER,
+ UnitOfPower.WATT,
+ ): SensorEntityDescription(
+ ENTITY_DESC_KEY_POWER,
+ name="Energy production power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ ),
}
# These descriptions are without device class.
@@ -269,9 +325,142 @@ ENTITY_DESCRIPTION_KEY_MAP = {
ENTITY_DESC_KEY_TOTAL_INCREASING,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription(
+ ENTITY_DESC_KEY_UV_INDEX,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UV_INDEX,
+ ),
}
+# Controller statistics descriptions
+ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [
+ SensorEntityDescription(
+ "messagesTX",
+ name="Successful messages (TX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "messagesRX",
+ name="Successful messages (RX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "messagesDroppedTX",
+ name="Messages dropped (TX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "messagesDroppedRX",
+ name="Messages dropped (RX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "NAK",
+ name="Messages not accepted",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "CAN", name="Collisions", state_class=SensorStateClass.TOTAL
+ ),
+ SensorEntityDescription(
+ "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL
+ ),
+ SensorEntityDescription(
+ "timeoutResponse",
+ name="Timed out responses",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "timeoutCallback",
+ name="Timed out callbacks",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel0.average",
+ name="Average background RSSI (channel 0)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel0.current",
+ name="Current background RSSI (channel 0)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel1.average",
+ name="Average background RSSI (channel 1)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel1.current",
+ name="Current background RSSI (channel 1)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel2.average",
+ name="Average background RSSI (channel 2)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ ),
+ SensorEntityDescription(
+ "backgroundRSSI.channel2.current",
+ name="Current background RSSI (channel 2)",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+]
+
+# Node statistics descriptions
+ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [
+ SensorEntityDescription(
+ "commandsRX",
+ name="Successful commands (RX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "commandsTX",
+ name="Successful commands (TX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "commandsDroppedRX",
+ name="Commands dropped (RX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "commandsDroppedTX",
+ name="Commands dropped (TX)",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "timeoutResponse",
+ name="Timed out responses",
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ "rtt",
+ name="Round Trip Time",
+ native_unit_of_measurement=UnitOfTime.MILLISECONDS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ "rssi",
+ name="RSSI",
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+]
+
+
def get_entity_description(
data: NumericSensorDataTemplateData,
) -> SensorEntityDescription:
@@ -311,11 +500,7 @@ async def async_setup_entry(
entity_description = get_entity_description(data)
- if info.platform_hint == "string_sensor":
- entities.append(
- ZWaveStringSensor(config_entry, driver, info, entity_description)
- )
- elif info.platform_hint == "numeric_sensor":
+ if info.platform_hint == "numeric_sensor":
entities.append(
ZWaveNumericSensor(
config_entry,
@@ -340,12 +525,7 @@ async def async_setup_entry(
ZWaveMeterSensor(config_entry, driver, info, entity_description)
)
else:
- LOGGER.warning(
- "Sensor not implemented for %s/%s",
- info.platform_hint,
- info.primary_value.property_name,
- )
- return
+ entities.append(ZwaveSensor(config_entry, driver, info, entity_description))
async_add_entities(entities)
@@ -356,6 +536,27 @@ async def async_setup_entry(
assert driver is not None # Driver is ready before platforms are loaded.
async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)])
+ @callback
+ def async_add_statistics_sensors(node: ZwaveNode) -> None:
+ """Add statistics sensors."""
+ driver = client.driver
+ assert driver is not None # Driver is ready before platforms are loaded.
+ async_add_entities(
+ [
+ ZWaveStatisticsSensor(
+ config_entry,
+ driver,
+ driver.controller if driver.controller.own_node == node else node,
+ entity_description,
+ )
+ for entity_description in (
+ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST
+ if driver.controller.own_node == node
+ else ENTITY_DESCRIPTION_NODE_STATISTICS_LIST
+ )
+ ]
+ )
+
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
@@ -372,6 +573,14 @@ async def async_setup_entry(
)
)
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ f"{DOMAIN}_{config_entry.entry_id}_add_statistics_sensors",
+ async_add_statistics_sensors,
+ )
+ )
+
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_RESET_METER,
@@ -383,7 +592,7 @@ async def async_setup_entry(
)
-class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity):
+class ZwaveSensor(ZWaveBaseEntity, SensorEntity):
"""Basic Representation of a Z-Wave sensor."""
def __init__(
@@ -395,34 +604,34 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity):
unit_of_measurement: str | None = None,
) -> None:
"""Initialize a ZWaveSensorBase entity."""
- super().__init__(config_entry, driver, info)
self.entity_description = entity_description
+ super().__init__(config_entry, driver, info)
self._attr_native_unit_of_measurement = unit_of_measurement
# Entity class attributes
self._attr_force_update = True
- self._attr_name = self.generate_name(include_value_name=True)
-
-
-class ZWaveStringSensor(ZwaveSensorBase):
- """Representation of a Z-Wave String sensor."""
+ if not entity_description.name:
+ self._attr_name = self.generate_name(include_value_name=True)
@property
- def native_value(self) -> str | None:
+ def native_value(self) -> StateType:
"""Return state of the sensor."""
- if self.info.primary_value.value is None:
- return None
- return str(self.info.primary_value.value)
+ key = str(self.info.primary_value.value)
+ if key not in self.info.primary_value.metadata.states:
+ return self.info.primary_value.value
+ return str(self.info.primary_value.metadata.states[key])
@property
def native_unit_of_measurement(self) -> str | None:
"""Return unit of measurement the value is expressed in."""
+ if (unit := super().native_unit_of_measurement) is not None:
+ return unit
if self.info.primary_value.metadata.unit is None:
return None
return str(self.info.primary_value.metadata.unit)
-class ZWaveNumericSensor(ZwaveSensorBase):
+class ZWaveNumericSensor(ZwaveSensor):
"""Representation of a Z-Wave Numeric sensor."""
@callback
@@ -439,18 +648,6 @@ class ZWaveNumericSensor(ZwaveSensorBase):
return 0
return round(float(self.info.primary_value.value), 2)
- @property
- def native_unit_of_measurement(self) -> str | None:
- """Return unit of measurement the value is expressed in."""
- if self.entity_description.native_unit_of_measurement is not None:
- return self.entity_description.native_unit_of_measurement
- if self._attr_native_unit_of_measurement is not None:
- return self._attr_native_unit_of_measurement
- if self.info.primary_value.metadata.unit is None:
- return None
-
- return str(self.info.primary_value.metadata.unit)
-
class ZWaveMeterSensor(ZWaveNumericSensor):
"""Representation of a Z-Wave Meter CC sensor."""
@@ -458,39 +655,42 @@ class ZWaveMeterSensor(ZWaveNumericSensor):
@property
def extra_state_attributes(self) -> Mapping[str, int | str] | None:
"""Return extra state attributes."""
- if meter_type := get_meter_type(self.info.primary_value):
- return {
- ATTR_METER_TYPE: meter_type.value,
- ATTR_METER_TYPE_NAME: meter_type.name,
- }
- return None
+ meter_type = get_meter_type(self.info.primary_value)
+ return {
+ ATTR_METER_TYPE: meter_type.value,
+ ATTR_METER_TYPE_NAME: meter_type.name,
+ }
async def async_reset_meter(
self, meter_type: int | None = None, value: int | None = None
) -> None:
"""Reset meter(s) on device."""
node = self.info.node
- primary_value = self.info.primary_value
- if (endpoint := primary_value.endpoint) is None:
- raise HomeAssistantError("Missing endpoint on device.")
+ endpoint = self.info.primary_value.endpoint or 0
options = {}
if meter_type is not None:
options[RESET_METER_OPTION_TYPE] = meter_type
if value is not None:
options[RESET_METER_OPTION_TARGET_VALUE] = value
args = [options] if options else []
- await node.endpoints[endpoint].async_invoke_cc_api(
- CommandClass.METER, "reset", *args, wait_for_result=False
- )
+ try:
+ await node.endpoints[endpoint].async_invoke_cc_api(
+ CommandClass.METER, "reset", *args, wait_for_result=False
+ )
+ except BaseZwaveJSServerError as err:
+ LOGGER.error(
+ "Failed to reset meters on node %s endpoint %s: %s", node, endpoint, err
+ )
+ raise HomeAssistantError from err
LOGGER.debug(
"Meters on node %s endpoint %s reset with the following options: %s",
node,
- primary_value.endpoint,
+ endpoint,
options,
)
-class ZWaveListSensor(ZwaveSensorBase):
+class ZWaveListSensor(ZwaveSensor):
"""Representation of a Z-Wave Numeric sensor with multiple states."""
def __init__(
@@ -507,21 +707,29 @@ class ZWaveListSensor(ZwaveSensorBase):
)
# Entity class attributes
- self._attr_name = self.generate_name(include_value_name=True)
+ # Notification sensors have the following name mapping (variables are property
+ # keys, name is property)
+ # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
+ self._attr_name = self.generate_name(
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[self.info.primary_value.property_key_name],
+ )
@property
- def native_value(self) -> str | None:
- """Return state of the sensor."""
- if self.info.primary_value.value is None:
- return None
- if (
- str(self.info.primary_value.value)
- not in self.info.primary_value.metadata.states
- ):
- return str(self.info.primary_value.value)
- return str(
- self.info.primary_value.metadata.states[str(self.info.primary_value.value)]
- )
+ def options(self) -> list[str] | None:
+ """Return options for enum sensor."""
+ if self.device_class == SensorDeviceClass.ENUM:
+ return list(self.info.primary_value.metadata.states.values())
+ return None
+
+ @property
+ def device_class(self) -> SensorDeviceClass | None:
+ """Return sensor device class."""
+ if (device_class := super().device_class) is not None:
+ return device_class
+ if self.info.primary_value.metadata.states:
+ return SensorDeviceClass.ENUM
+ return None
@property
def extra_state_attributes(self) -> dict[str, str] | None:
@@ -532,9 +740,11 @@ class ZWaveListSensor(ZwaveSensorBase):
return {ATTR_VALUE: value}
-class ZWaveConfigParameterSensor(ZwaveSensorBase):
+class ZWaveConfigParameterSensor(ZWaveListSensor):
"""Representation of a Z-Wave config parameter sensor."""
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
def __init__(
self,
config_entry: ConfigEntry,
@@ -554,33 +764,24 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase):
self._attr_name = self.generate_name(
alternate_value_name=self.info.primary_value.property_name,
additional_info=[property_key_name] if property_key_name else None,
- name_prefix="Config parameter",
)
@property
- def native_value(self) -> str | None:
- """Return state of the sensor."""
- if self.info.primary_value.value is None:
- return None
+ def device_class(self) -> SensorDeviceClass | None:
+ """Return sensor device class."""
+ if (device_class := super(ZwaveSensor, self).device_class) is not None:
+ return device_class
if (
- self._primary_value.configuration_value_type == ConfigurationValueType.RANGE
- or (
- str(self.info.primary_value.value)
- not in self.info.primary_value.metadata.states
- )
+ self._primary_value.configuration_value_type
+ == ConfigurationValueType.ENUMERATED
):
- return str(self.info.primary_value.value)
- return str(
- self.info.primary_value.metadata.states[str(self.info.primary_value.value)]
- )
+ return SensorDeviceClass.ENUM
+ return None
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the device specific state attributes."""
- if (
- self._primary_value.configuration_value_type == ConfigurationValueType.RANGE
- or (value := self.info.primary_value.value) is None
- ):
+ if (value := self.info.primary_value.value) is None:
return None
# add the value's int value as property for multi-value (list) items
return {ATTR_VALUE: value}
@@ -609,6 +810,9 @@ class ZWaveNodeStatusSensor(SensorEntity):
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
+ # We log an error instead of raising an exception because this service call occurs
+ # in a separate task since it is called via the dispatcher and we don't want to
+ # raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
@@ -637,6 +841,9 @@ class ZWaveNodeStatusSensor(SensorEntity):
self.async_poll_value,
)
)
+ # we don't listen for `remove_entity_on_ready_node` signal because this entity
+ # is created when the node is added which occurs before ready. It only needs to
+ # be removed if the node is removed from the network.
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -646,3 +853,93 @@ class ZWaveNodeStatusSensor(SensorEntity):
)
self._attr_native_value: str = self.node.status.name.lower()
self.async_write_ha_state()
+
+
+class ZWaveStatisticsSensor(SensorEntity):
+ """Representation of a node/controller statistics sensor."""
+
+ _attr_should_poll = False
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ config_entry: ConfigEntry,
+ driver: Driver,
+ statistics_src: ZwaveNode | Controller,
+ description: SensorEntityDescription,
+ ) -> None:
+ """Initialize a Z-Wave statistics entity."""
+ self.entity_description = description
+ self.config_entry = config_entry
+ self.statistics_src = statistics_src
+ node = (
+ statistics_src.own_node
+ if isinstance(statistics_src, Controller)
+ else statistics_src
+ )
+ assert node
+
+ # Entity class attributes
+ self._base_unique_id = get_valueless_base_unique_id(driver, node)
+ self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}"
+ # device may not be precreated in main handler yet
+ self._attr_device_info = get_device_info(driver, node)
+
+ async def async_poll_value(self, _: bool) -> None:
+ """Poll a value."""
+ # We log an error instead of raising an exception because this service call occurs
+ # in a separate task since it is called via the dispatcher and we don't want to
+ # raise the exception in that separate task because it is confusing to the user.
+ LOGGER.error(
+ "There is no value to refresh for this entity so the zwave_js.refresh_value"
+ " service won't work for it"
+ )
+
+ def _get_data_from_statistics(
+ self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType
+ ) -> int | None:
+ """Get the data from the statistics dict."""
+ if "." not in self.entity_description.key:
+ return cast(int | None, statistics.get(self.entity_description.key))
+
+ # If key contains dots, we need to traverse the dict to get to the right value
+ for key in self.entity_description.key.split("."):
+ if key not in statistics:
+ return None
+ statistics = statistics[key] # type: ignore[literal-required]
+ return cast(int, statistics)
+
+ @callback
+ def statistics_updated(self, event_data: dict) -> None:
+ """Call when statistics updated event is received."""
+ self._attr_native_value = self._get_data_from_statistics(
+ event_data["statistics"]
+ )
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{DOMAIN}_{self.unique_id}_poll_value",
+ self.async_poll_value,
+ )
+ )
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{DOMAIN}_{self._base_unique_id}_remove_entity",
+ self.async_remove,
+ )
+ )
+ self.async_on_remove(
+ self.statistics_src.on("statistics updated", self.statistics_updated)
+ )
+
+ # Set initial state
+ self._attr_native_value = self._get_data_from_statistics(
+ self.statistics_src.statistics.data
+ )
diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py
index 47a16ee12737..133cb4074055 100644
--- a/homeassistant/components/zwave_js/services.py
+++ b/homeassistant/components/zwave_js/services.py
@@ -213,6 +213,7 @@ class ZWaveServices:
cv.ensure_list, [cv.string]
),
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int),
vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any(
vol.Coerce(int), cv.string
),
@@ -247,6 +248,7 @@ class ZWaveServices:
cv.ensure_list, [cv.string]
),
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int),
vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int),
vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(
vol.Coerce(int),
@@ -413,6 +415,7 @@ class ZWaveServices:
async def async_set_config_parameter(self, service: ServiceCall) -> None:
"""Set a config value on a node."""
nodes: set[ZwaveNode] = service.data[const.ATTR_NODES]
+ endpoint = service.data[const.ATTR_ENDPOINT]
property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER]
property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK)
new_value = service.data[const.ATTR_CONFIG_VALUE]
@@ -424,6 +427,7 @@ class ZWaveServices:
new_value,
property_or_property_name,
property_key=property_key,
+ endpoint=endpoint,
)
for node in nodes
),
@@ -448,6 +452,7 @@ class ZWaveServices:
) -> None:
"""Bulk set multiple partial config values on a node."""
nodes: set[ZwaveNode] = service.data[const.ATTR_NODES]
+ endpoint = service.data[const.ATTR_ENDPOINT]
property_ = service.data[const.ATTR_CONFIG_PARAMETER]
new_value = service.data[const.ATTR_CONFIG_VALUE]
@@ -457,6 +462,7 @@ class ZWaveServices:
node,
property_,
new_value,
+ endpoint=endpoint,
)
for node in nodes
),
@@ -627,8 +633,11 @@ class ZWaveServices:
"calls will still work for now but the service will be removed in a "
"future release"
)
- nodes: set[ZwaveNode] = service.data[const.ATTR_NODES]
- await asyncio.gather(*(node.async_ping() for node in nodes))
+ nodes: list[ZwaveNode] = list(service.data[const.ATTR_NODES])
+ results = await asyncio.gather(
+ *(node.async_ping() for node in nodes), return_exceptions=True
+ )
+ raise_exceptions_from_results(nodes, results)
async def async_invoke_cc_api(self, service: ServiceCall) -> None:
"""Invoke a command class API."""
diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml
index b9209c6904f7..05e2f8bd9fbb 100644
--- a/homeassistant/components/zwave_js/services.yaml
+++ b/homeassistant/components/zwave_js/services.yaml
@@ -46,6 +46,14 @@ set_config_parameter:
entity:
integration: zwave_js
fields:
+ endpoint:
+ name: Endpoint
+ description: The configuration parameter's endpoint.
+ example: 1
+ default: 0
+ required: false
+ selector:
+ text:
parameter:
name: Parameter
description: The (name or id of the) configuration parameter you want to configure.
@@ -53,6 +61,12 @@ set_config_parameter:
required: true
selector:
text:
+ bitmask:
+ name: Bitmask
+ description: Target a specific bitmask (see the documentation for more information).
+ advanced: true
+ selector:
+ text:
value:
name: Value
description: The new value to set for this configuration parameter.
@@ -60,12 +74,6 @@ set_config_parameter:
required: true
selector:
text:
- bitmask:
- name: Bitmask
- description: Target a specific bitmask (see the documentation for more information).
- advanced: true
- selector:
- text:
bulk_set_partial_config_parameters:
name: Bulk set partial configuration parameters for a Z-Wave device (Advanced).
@@ -74,6 +82,14 @@ bulk_set_partial_config_parameters:
entity:
integration: zwave_js
fields:
+ endpoint:
+ name: Endpoint
+ description: The configuration parameter's endpoint.
+ example: 1
+ default: 0
+ required: false
+ selector:
+ text:
parameter:
name: Parameter
description: The id of the configuration parameter you want to configure.
diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py
index 5a53d1155281..6de6b0f4e45b 100644
--- a/homeassistant/components/zwave_js/siren.py
+++ b/homeassistant/components/zwave_js/siren.py
@@ -79,14 +79,6 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity):
return None
return bool(self.info.primary_value.value)
- async def async_set_value(
- self, new_value: int, options: dict[str, Any] | None = None
- ) -> None:
- """Set a value on a siren node."""
- await self.info.node.async_set_value(
- self.info.primary_value, new_value, options=options
- )
-
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
tone_id: int | None = kwargs.get(ATTR_TONE)
@@ -95,11 +87,13 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity):
options["volume"] = round(volume * 100)
# Play the default tone if a tone isn't provided
if tone_id is None:
- await self.async_set_value(ToneID.DEFAULT, options)
+ await self._async_set_value(
+ self.info.primary_value, ToneID.DEFAULT, options
+ )
return
- await self.async_set_value(tone_id, options)
+ await self._async_set_value(self.info.primary_value, tone_id, options)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- await self.async_set_value(ToneID.OFF)
+ await self._async_set_value(self.info.primary_value, ToneID.OFF)
diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py
index 450a0cfd31d9..409bcd1dbb73 100644
--- a/homeassistant/components/zwave_js/switch.py
+++ b/homeassistant/components/zwave_js/switch.py
@@ -12,6 +12,7 @@ from zwave_js_server.model.driver import Driver
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -41,6 +42,10 @@ async def async_setup_entry(
entities.append(
ZWaveBarrierEventSignalingSwitch(config_entry, driver, info)
)
+ elif info.platform_hint == "config_parameter":
+ entities.append(ZWaveConfigParameterSwitch(config_entry, driver, info))
+ elif info.platform_hint == "indicator":
+ entities.append(ZWaveIndicatorSwitch(config_entry, driver, info))
else:
entities.append(ZWaveSwitch(config_entry, driver, info))
@@ -77,12 +82,24 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
if self._target_value is not None:
- await self.info.node.async_set_value(self._target_value, True)
+ await self._async_set_value(self._target_value, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
if self._target_value is not None:
- await self.info.node.async_set_value(self._target_value, False)
+ await self._async_set_value(self._target_value, False)
+
+
+class ZWaveIndicatorSwitch(ZWaveSwitch):
+ """Representation of a Z-Wave Indicator CC switch."""
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize the switch."""
+ super().__init__(config_entry, driver, info)
+ self._target_value = self.info.primary_value
+ self._attr_name = self.generate_name(include_value_name=True)
class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity):
@@ -115,7 +132,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
- await self.info.node.async_set_value(
+ await self._async_set_value(
self.info.primary_value, BarrierEventSignalingSubsystemState.ON
)
# this value is not refreshed, so assume success
@@ -124,7 +141,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- await self.info.node.async_set_value(
+ await self._async_set_value(
self.info.primary_value, BarrierEventSignalingSubsystemState.OFF
)
# this value is not refreshed, so assume success
@@ -138,3 +155,30 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity):
self._state = (
self.info.primary_value.value == BarrierEventSignalingSubsystemState.ON
)
+
+
+class ZWaveConfigParameterSwitch(ZWaveSwitch):
+ """Representation of a Z-Wave config parameter switch."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+
+ def __init__(
+ self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ ) -> None:
+ """Initialize a ZWaveConfigParameterSwitch entity."""
+ super().__init__(config_entry, driver, info)
+
+ property_key_name = self.info.primary_value.property_key_name
+ # Entity class attributes
+ self._attr_name = self.generate_name(
+ alternate_value_name=self.info.primary_value.property_name,
+ additional_info=[property_key_name] if property_key_name else None,
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self._async_set_value(self.info.primary_value, 1)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self._async_set_value(self.info.primary_value, 0)
diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py
index f747c25c71bc..94cb05b1b20d 100644
--- a/homeassistant/components/zwave_js/trigger.py
+++ b/homeassistant/components/zwave_js/trigger.py
@@ -1,8 +1,6 @@
"""Z-Wave JS trigger dispatcher."""
from __future__ import annotations
-from typing import cast
-
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.trigger import (
@@ -33,10 +31,7 @@ async def async_validate_trigger_config(
) -> ConfigType:
"""Validate config."""
platform = _get_trigger_platform(config)
- if hasattr(platform, "async_validate_trigger_config"):
- return await platform.async_validate_trigger_config(hass, config)
-
- return cast(ConfigType, platform.TRIGGER_SCHEMA(config))
+ return await platform.async_validate_trigger_config(hass, config)
async def async_attach_trigger(
diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py
index 70d12b22dedc..5b7c157552ad 100644
--- a/homeassistant/components/zwave_js/update.py
+++ b/homeassistant/components/zwave_js/update.py
@@ -42,6 +42,7 @@ PARALLEL_UPDATES = 1
UPDATE_DELAY_STRING = "delay"
UPDATE_DELAY_INTERVAL = 5 # In minutes
+ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware"
@dataclass
@@ -53,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData):
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the extra data."""
return {
- "latest_version_firmware": asdict(self.latest_version_firmware)
+ ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware)
if self.latest_version_firmware
else None
}
@@ -61,7 +62,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData):
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData:
"""Initialize the extra data from a dict."""
- if not (firmware_dict := data["latest_version_firmware"]):
+ if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]):
return cls(None)
return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict))
@@ -291,6 +292,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
+ # We log an error instead of raising an exception because this service call occurs
+ # in a separate task since it is called via the dispatcher and we don't want to
+ # raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
@@ -317,26 +321,30 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_ready_node",
+ f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started",
self.async_remove,
)
)
# If we have a complete previous state, use that to set the latest version
- if (state := await self.async_get_last_state()) and (
- extra_data := await self.async_get_last_extra_data()
+ if (
+ (state := await self.async_get_last_state())
+ and (latest_version := state.attributes.get(ATTR_LATEST_VERSION))
+ is not None
+ and (extra_data := await self.async_get_last_extra_data())
):
- self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION]
+ self._attr_latest_version = latest_version
self._latest_version_firmware = (
ZWaveNodeFirmwareUpdateExtraStoredData.from_dict(
extra_data.as_dict()
).latest_version_firmware
)
- # If we have no state to restore, we can set the latest version to installed
- # so that the entity starts as off. If we have partial restore data due to an
- # upgrade to an HA version where this feature is released from one that is not
- # the entity will start in an unknown state until we can correct on next update
- elif not state:
+ # If we have no state or latest version to restore, we can set the latest
+ # version to installed so that the entity starts as off. If we have partial
+ # restore data due to an upgrade to an HA version where this feature is released
+ # from one that is not the entity will start in an unknown state until we can
+ # correct on next update
+ elif not state or not latest_version:
self._attr_latest_version = self._attr_installed_version
# Spread updates out in 5 minute increments to avoid flooding the network
diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json
index 1a4d9cccbe4b..5870874efa80 100644
--- a/homeassistant/components/zwave_me/manifest.json
+++ b/homeassistant/components/zwave_me/manifest.json
@@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_me",
"iot_class": "local_push",
- "requirements": ["zwave_me_ws==0.4.2", "url-normalize==1.4.3"],
+ "requirements": ["zwave_me_ws==0.4.3", "url-normalize==1.4.3"],
"zeroconf": [
{
"type": "_hap._tcp.local.",
diff --git a/homeassistant/config.py b/homeassistant/config.py
index 0a5da91d9421..4ef1da1e6470 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -96,7 +96,7 @@ default_config:
frontend:
themes: !include_dir_merge_named themes
-# Text to speech
+# Text-to-speech
tts:
- platform: google_translate
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 6bb2204ce26e..9308d364ecb2 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -7,14 +7,14 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023
-MINOR_VERSION: Final = 5
-PATCH_VERSION: Final = "4"
+MINOR_VERSION: Final = 6
+PATCH_VERSION: Final = "0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
-REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
+REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)
# Truthy date string triggers showing related deprecation warning messages.
-REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""
+REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2023.8"
# Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}"
@@ -31,6 +31,8 @@ class Platform(StrEnum):
CAMERA = "camera"
CLIMATE = "climate"
COVER = "cover"
+ DATE = "date"
+ DATETIME = "datetime"
DEVICE_TRACKER = "device_tracker"
FAN = "fan"
GEO_LOCATION = "geo_location"
@@ -50,6 +52,7 @@ class Platform(StrEnum):
STT = "stt"
SWITCH = "switch"
TEXT = "text"
+ TIME = "time"
TTS = "tts"
VACUUM = "vacuum"
UPDATE = "update"
@@ -1163,6 +1166,11 @@ HASSIO_USER_NAME = "Supervisor"
SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations"
+# Date/Time formats
+FORMAT_DATE: Final = "%Y-%m-%d"
+FORMAT_TIME: Final = "%H:%M:%S"
+FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}"
+
class EntityCategory(StrEnum):
"""Category of an entity.
diff --git a/homeassistant/core.py b/homeassistant/core.py
index f7cfcf442051..1966045f5692 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -25,6 +25,7 @@ import os
import pathlib
import re
import threading
+import time
from time import monotonic
from typing import (
TYPE_CHECKING,
@@ -80,8 +81,13 @@ from .exceptions import (
Unauthorized,
)
from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior
+from .helpers.json import json_dumps
from .util import dt as dt_util, location, ulid as ulid_util
-from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe
+from .util.async_ import (
+ cancelling,
+ run_callback_threadsafe,
+ shutdown_run_callback_threadsafe,
+)
from .util.read_only_dict import ReadOnlyDict
from .util.timeout import TimeoutManager
from .util.unit_system import (
@@ -161,9 +167,19 @@ def split_entity_id(entity_id: str) -> tuple[str, str]:
return domain, object_id
-VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool:
+ """Test if a domain a valid format."""
+ return VALID_DOMAIN.match(domain) is not None
+
+
+@functools.lru_cache(512)
def valid_entity_id(entity_id: str) -> bool:
"""Test if an entity ID is a valid format.
@@ -172,11 +188,6 @@ def valid_entity_id(entity_id: str) -> bool:
return VALID_ENTITY_ID.match(entity_id) is not None
-def valid_state(state: str) -> bool:
- """Test if a state is valid."""
- return len(state) <= MAX_LENGTH_STATE_STATE
-
-
def callback(func: _CallableT) -> _CallableT:
"""Annotation to mark method as safe to call from within the event loop."""
setattr(func, "_hass_callback", True)
@@ -667,7 +678,11 @@ class HomeAssistant:
start_time: float | None = None
current_task = asyncio.current_task()
- while tasks := [task for task in self._tasks if task is not current_task]:
+ while tasks := [
+ task
+ for task in self._tasks
+ if task is not current_task and not cancelling(task)
+ ]:
await self._await_and_log_pending(tasks)
if start_time is None:
@@ -780,7 +795,7 @@ class HomeAssistant:
# while we are awaiting canceled tasks to get their result
# which will result in the set size changing during iteration
for task in list(running_tasks):
- if task.done():
+ if task.done() or cancelling(task):
# Since we made a copy we need to check
# to see if the task finished while we
# were awaiting another task
@@ -850,7 +865,7 @@ class HomeAssistant:
class Context:
"""The context that triggered something."""
- __slots__ = ("user_id", "parent_id", "id", "origin_event")
+ __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict")
def __init__(
self,
@@ -863,14 +878,23 @@ class Context:
self.user_id = user_id
self.parent_id = parent_id
self.origin_event: Event | None = None
+ self._as_dict: ReadOnlyDict[str, str | None] | None = None
def __eq__(self, other: Any) -> bool:
"""Compare contexts."""
return bool(self.__class__ == other.__class__ and self.id == other.id)
- def as_dict(self) -> dict[str, str | None]:
+ def as_dict(self) -> ReadOnlyDict[str, str | None]:
"""Return a dictionary representation of the context."""
- return {"id": self.id, "parent_id": self.parent_id, "user_id": self.user_id}
+ if not self._as_dict:
+ self._as_dict = ReadOnlyDict(
+ {
+ "id": self.id,
+ "parent_id": self.parent_id,
+ "user_id": self.user_id,
+ }
+ )
+ return self._as_dict
class EventOrigin(enum.Enum):
@@ -887,7 +911,7 @@ class EventOrigin(enum.Enum):
class Event:
"""Representation of an event within the bus."""
- __slots__ = ["event_type", "data", "origin", "time_fired", "context"]
+ __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict")
def __init__(
self,
@@ -905,19 +929,24 @@ class Event:
self.context: Context = context or Context(
id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired))
)
+ self._as_dict: ReadOnlyDict[str, Any] | None = None
- def as_dict(self) -> dict[str, Any]:
+ def as_dict(self) -> ReadOnlyDict[str, Any]:
"""Create a dict representation of this Event.
Async friendly.
"""
- return {
- "event_type": self.event_type,
- "data": dict(self.data),
- "origin": str(self.origin.value),
- "time_fired": self.time_fired.isoformat(),
- "context": self.context.as_dict(),
- }
+ if not self._as_dict:
+ self._as_dict = ReadOnlyDict(
+ {
+ "event_type": self.event_type,
+ "data": ReadOnlyDict(self.data),
+ "origin": str(self.origin.value),
+ "time_fired": self.time_fired.isoformat(),
+ "context": self.context.as_dict(),
+ }
+ )
+ return self._as_dict
def __repr__(self) -> str:
"""Return the representation."""
@@ -1189,7 +1218,7 @@ class State:
object_id: Object id of this state.
"""
- __slots__ = [
+ __slots__ = (
"entity_id",
"state",
"attributes",
@@ -1199,8 +1228,9 @@ class State:
"domain",
"object_id",
"_as_dict",
- "_as_compressed_state",
- ]
+ "_as_dict_json",
+ "_as_compressed_state_json",
+ )
def __init__(
self,
@@ -1221,7 +1251,7 @@ class State:
"Format should be ."
)
- if not valid_state(state):
+ if len(state) > MAX_LENGTH_STATE_STATE:
raise InvalidStateError(
f"Invalid state encountered for entity ID: {entity_id}. "
"State max length is 255 characters."
@@ -1235,7 +1265,8 @@ class State:
self.context = context or Context()
self.domain, self.object_id = split_entity_id(self.entity_id)
self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None
- self._as_compressed_state: dict[str, Any] | None = None
+ self._as_dict_json: str | None = None
+ self._as_compressed_state_json: str | None = None
@property
def name(self) -> str:
@@ -1265,11 +1296,17 @@ class State:
"attributes": self.attributes,
"last_changed": last_changed_isoformat,
"last_updated": last_updated_isoformat,
- "context": ReadOnlyDict(self.context.as_dict()),
+ "context": self.context.as_dict(),
}
)
return self._as_dict
+ def as_dict_json(self) -> str:
+ """Return a JSON string of the State."""
+ if not self._as_dict_json:
+ self._as_dict_json = json_dumps(self.as_dict())
+ return self._as_dict_json
+
def as_compressed_state(self) -> dict[str, Any]:
"""Build a compressed dict of a state for adds.
@@ -1277,8 +1314,6 @@ class State:
Sends c (context) as a string if it only contains an id.
"""
- if self._as_compressed_state:
- return self._as_compressed_state
state_context = self.context
if state_context.parent_id is None and state_context.user_id is None:
context: dict[str, Any] | str = state_context.id
@@ -1294,9 +1329,21 @@ class State:
compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(
self.last_updated
)
- self._as_compressed_state = compressed_state
return compressed_state
+ def as_compressed_state_json(self) -> str:
+ """Build a compressed JSON key value pair of a state for adds.
+
+ The JSON string is a key value pair of the entity_id and the compressed state.
+
+ It is used for sending multiple states in a single message.
+ """
+ if not self._as_compressed_state_json:
+ self._as_compressed_state_json = json_dumps(
+ {self.entity_id: self.as_compressed_state()}
+ )[1:-1]
+ return self._as_compressed_state_json
+
@classmethod
def from_dict(cls, json_dict: dict[str, Any]) -> Self | None:
"""Initialize a state from a dict.
@@ -1569,10 +1616,24 @@ class StateMachine:
if same_state and same_attr:
return
- now = dt_util.utcnow()
-
if context is None:
- context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now)))
+ # It is much faster to convert a timestamp to a utc datetime object
+ # than converting a utc datetime object to a timestamp since cpython
+ # does not have a fast path for handling the UTC timezone and has to do
+ # multiple local timezone conversions.
+ #
+ # from_timestamp implementation:
+ # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L2936
+ #
+ # timestamp implementation:
+ # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387
+ # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323
+ timestamp = time.time()
+ now = dt_util.utc_from_timestamp(timestamp)
+ context = Context(id=ulid_util.ulid_at_time(timestamp))
+ else:
+ now = dt_util.utcnow()
+
state = State(
entity_id,
new_state,
diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py
index 59e76a9c8aeb..d1b330b5dbe7 100644
--- a/homeassistant/generated/application_credentials.py
+++ b/homeassistant/generated/application_credentials.py
@@ -20,4 +20,5 @@ APPLICATION_CREDENTIALS = [
"withings",
"xbox",
"yolink",
+ "youtube",
]
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 066fb6fb8b06..ca81e7befaf8 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -32,6 +32,7 @@ FLOWS = {
"airvisual",
"airvisual_pro",
"airzone",
+ "airzone_cloud",
"aladdin_connect",
"alarmdecoder",
"amberelectric",
@@ -103,6 +104,7 @@ FLOWS = {
"dsmr",
"dsmr_reader",
"dunehd",
+ "dwd_weather_warnings",
"dynalite",
"eafm",
"easyenergy",
@@ -112,6 +114,7 @@ FLOWS = {
"edl21",
"efergy",
"eight_sleep",
+ "electrasmart",
"elgato",
"elkm1",
"elmax",
@@ -152,6 +155,7 @@ FLOWS = {
"garages_amsterdam",
"gdacs",
"generic",
+ "geo_json_events",
"geocaching",
"geofency",
"geonetnz_quakes",
@@ -164,6 +168,7 @@ FLOWS = {
"goodwe",
"google",
"google_assistant_sdk",
+ "google_generative_ai_conversation",
"google_mail",
"google_sheets",
"google_travel_time",
@@ -214,6 +219,7 @@ FLOWS = {
"jellyfin",
"juicenet",
"justnimbus",
+ "jvc_projector",
"kaleidescape",
"keenetic_ndms2",
"kegtron",
@@ -228,6 +234,7 @@ FLOWS = {
"lacrosse_view",
"lametric",
"landisgyr_heat_meter",
+ "lastfm",
"launch_library",
"laundrify",
"ld2410_ble",
@@ -515,6 +522,7 @@ FLOWS = {
"yeelight",
"yolink",
"youless",
+ "youtube",
"zamg",
"zerproc",
"zeversolar",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index adcc32fe8d92..ce5536e62a44 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -97,6 +97,18 @@ DHCP: list[dict[str, str | bool]] = [
"domain": "broadlink",
"macaddress": "C8F742*",
},
+ {
+ "domain": "broadlink",
+ "macaddress": "E81656*",
+ },
+ {
+ "domain": "broadlink",
+ "macaddress": "E87072*",
+ },
+ {
+ "domain": "broadlink",
+ "macaddress": "EC0BAE*",
+ },
{
"domain": "dlink",
"hostname": "dsp-w215",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index d85765aec4c3..6f13633e3060 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -137,9 +137,20 @@
},
"airzone": {
"name": "Airzone",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "local_polling"
+ "integrations": {
+ "airzone": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling",
+ "name": "Airzone"
+ },
+ "airzone_cloud": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "Airzone Cloud"
+ }
+ }
},
"aladdin_connect": {
"name": "Aladdin Connect",
@@ -1200,7 +1211,7 @@
"dwd_weather_warnings": {
"name": "Deutscher Wetterdienst (DWD) Weather Warnings",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "cloud_polling"
},
"dweet": {
@@ -1299,6 +1310,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "electrasmart": {
+ "name": "Electra Smart",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"elgato": {
"name": "Elgato",
"integrations": {
@@ -1893,7 +1910,7 @@
"geo_json_events": {
"name": "GeoJSON",
"integration_type": "service",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "cloud_polling"
},
"geo_rss_events": {
@@ -1978,12 +1995,6 @@
}
}
},
- "goalfeed": {
- "name": "Goalfeed",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_push"
- },
"goalzero": {
"name": "Goal Zero Yeti",
"integration_type": "device",
@@ -2029,6 +2040,12 @@
"iot_class": "cloud_polling",
"name": "Google Domains"
},
+ "google_generative_ai_conversation": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "Google Generative AI Conversation"
+ },
"google_mail": {
"integration_type": "service",
"config_flow": true,
@@ -2057,7 +2074,7 @@
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_push",
- "name": "Google Translate Text-to-Speech"
+ "name": "Google Translate text-to-speech"
},
"google_travel_time": {
"integration_type": "hub",
@@ -2093,6 +2110,12 @@
"config_flow": true,
"iot_class": "cloud_push",
"name": "Dialogflow"
+ },
+ "youtube": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "YouTube"
}
}
},
@@ -2658,6 +2681,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "jvc_projector": {
+ "name": "JVC Projector",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"kaiterra": {
"name": "Kaiterra",
"integration_type": "hub",
@@ -2817,7 +2846,7 @@
"lastfm": {
"name": "Last.fm",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "cloud_polling"
},
"launch_library": {
@@ -3622,7 +3651,7 @@
"name": "NextBus",
"integration_type": "hub",
"config_flow": false,
- "iot_class": "local_polling"
+ "iot_class": "cloud_polling"
},
"nextcloud": {
"name": "Nextcloud",
@@ -4144,6 +4173,11 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "piper": {
+ "name": "Piper",
+ "integration_type": "virtual",
+ "supported_by": "wyoming"
+ },
"pjlink": {
"name": "PJLink",
"integration_type": "hub",
@@ -5074,7 +5108,7 @@
"name": "Snapcast",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "local_polling"
+ "iot_class": "local_push"
},
"snips": {
"name": "Snips",
@@ -6180,6 +6214,11 @@
"config_flow": true,
"iot_class": "cloud_push"
},
+ "whisper": {
+ "name": "Whisper",
+ "integration_type": "virtual",
+ "supported_by": "wyoming"
+ },
"whois": {
"name": "Whois",
"integration_type": "service",
@@ -6349,6 +6388,12 @@
"config_flow": true,
"iot_class": "local_push",
"name": "Yale Access Bluetooth"
+ },
+ "yale_home": {
+ "integration_type": "virtual",
+ "config_flow": false,
+ "supported_by": "august",
+ "name": "Yale Home"
}
}
},
diff --git a/homeassistant/generated/microsoft_tts.py b/homeassistant/generated/microsoft_tts.py
new file mode 100644
index 000000000000..2b894e834ee1
--- /dev/null
+++ b/homeassistant/generated/microsoft_tts.py
@@ -0,0 +1,154 @@
+"""Automatically generated file.
+
+To update, run python3 -m script.microsoft_tts
+"""
+
+SUPPORTED_LANGUAGES = {
+ "af-za",
+ "am-et",
+ "ar-ae",
+ "ar-bh",
+ "ar-dz",
+ "ar-eg",
+ "ar-iq",
+ "ar-jo",
+ "ar-kw",
+ "ar-lb",
+ "ar-ly",
+ "ar-ma",
+ "ar-om",
+ "ar-qa",
+ "ar-sa",
+ "ar-sy",
+ "ar-tn",
+ "ar-ye",
+ "az-az",
+ "bg-bg",
+ "bn-bd",
+ "bn-in",
+ "bs-ba",
+ "ca-es",
+ "cs-cz",
+ "cy-gb",
+ "da-dk",
+ "de-at",
+ "de-ch",
+ "de-de",
+ "el-gr",
+ "en-au",
+ "en-ca",
+ "en-gb",
+ "en-hk",
+ "en-ie",
+ "en-in",
+ "en-ke",
+ "en-ng",
+ "en-nz",
+ "en-ph",
+ "en-sg",
+ "en-tz",
+ "en-us",
+ "en-za",
+ "es-ar",
+ "es-bo",
+ "es-cl",
+ "es-co",
+ "es-cr",
+ "es-cu",
+ "es-do",
+ "es-ec",
+ "es-es",
+ "es-gq",
+ "es-gt",
+ "es-hn",
+ "es-mx",
+ "es-ni",
+ "es-pa",
+ "es-pe",
+ "es-pr",
+ "es-py",
+ "es-sv",
+ "es-us",
+ "es-uy",
+ "es-ve",
+ "et-ee",
+ "eu-es",
+ "fa-ir",
+ "fi-fi",
+ "fil-ph",
+ "fr-be",
+ "fr-ca",
+ "fr-ch",
+ "fr-fr",
+ "ga-ie",
+ "gl-es",
+ "gu-in",
+ "he-il",
+ "hi-in",
+ "hr-hr",
+ "hu-hu",
+ "hy-am",
+ "id-id",
+ "is-is",
+ "it-it",
+ "ja-jp",
+ "jv-id",
+ "ka-ge",
+ "kk-kz",
+ "km-kh",
+ "kn-in",
+ "ko-kr",
+ "lo-la",
+ "lt-lt",
+ "lv-lv",
+ "mk-mk",
+ "ml-in",
+ "mn-mn",
+ "mr-in",
+ "ms-my",
+ "mt-mt",
+ "my-mm",
+ "nb-no",
+ "ne-np",
+ "nl-be",
+ "nl-nl",
+ "pl-pl",
+ "ps-af",
+ "pt-br",
+ "pt-pt",
+ "ro-ro",
+ "ru-ru",
+ "si-lk",
+ "sk-sk",
+ "sl-si",
+ "so-so",
+ "sq-al",
+ "sr-rs",
+ "su-id",
+ "sv-se",
+ "sw-ke",
+ "sw-tz",
+ "ta-in",
+ "ta-lk",
+ "ta-my",
+ "ta-sg",
+ "te-in",
+ "th-th",
+ "tr-tr",
+ "uk-ua",
+ "ur-in",
+ "ur-pk",
+ "uz-uz",
+ "vi-vn",
+ "wuu-cn",
+ "yue-cn",
+ "zh-cn",
+ "zh-cn-henan",
+ "zh-cn-liaoning",
+ "zh-cn-shaanxi",
+ "zh-cn-shandong",
+ "zh-cn-sichuan",
+ "zh-hk",
+ "zh-tw",
+ "zu-za",
+}
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 1771d9d63bfd..93ccb404ae45 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -279,6 +279,16 @@ ZEROCONF = {
"domain": "apple_tv",
},
],
+ "_amzn-alexa._tcp.local.": [
+ {
+ "domain": "roomba",
+ "name": "irobot-*",
+ },
+ {
+ "domain": "roomba",
+ "name": "roomba-*",
+ },
+ ],
"_androidtvremote2._tcp.local.": [
{
"domain": "androidtv_remote",
diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py
index 78806cb5ae17..8208c7748870 100644
--- a/homeassistant/helpers/aiohttp_client.py
+++ b/homeassistant/helpers/aiohttp_client.py
@@ -37,10 +37,11 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format(
APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info
)
-ENABLE_CLEANUP_CLOSED = sys.version_info < (3, 11, 1)
+ENABLE_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4)
# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly
# see https://github.com/aio-libs/aiohttp/issues/7252
# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540
+# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py
index 072fed4220bf..fe4e5473092d 100644
--- a/homeassistant/helpers/config_entry_oauth2_flow.py
+++ b/homeassistant/helpers/config_entry_oauth2_flow.py
@@ -542,7 +542,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str:
@callback
-def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None:
+def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None:
"""JWT encode data."""
secret: str | None = hass.data.get(DATA_JWT_SECRET)
@@ -550,6 +550,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None:
return None
try:
- return jwt.decode(encoded, secret, algorithms=["HS256"])
+ return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return]
except jwt.InvalidTokenError:
return None
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 0f53c9108c89..6551b7e4709d 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -567,6 +567,10 @@ def string(value: Any) -> str:
if value is None:
raise vol.Invalid("string value is None")
+ # This is expected to be the most common case, so check it first.
+ if type(value) is str: # pylint: disable=unidiomatic-typecheck
+ return value
+
if isinstance(value, template_helper.ResultWrapper):
value = value.render_result
@@ -862,7 +866,7 @@ def _deprecated_or_removed(
logger_func(warning, *arguments)
value = config[key]
- if replacement_key:
+ if replacement_key or option_removed:
config.pop(key)
else:
value = default
@@ -1041,6 +1045,35 @@ def expand_condition_shorthand(value: Any | None) -> Any:
# Schemas
+def empty_config_schema(domain: str) -> Callable[[dict], dict]:
+ """Return a config schema which logs if there are configuration parameters."""
+
+ module = inspect.getmodule(inspect.stack(context=0)[2].frame)
+ if module is not None:
+ module_name = module.__name__
+ else:
+ # If Python is unable to access the sources files, the call stack frame
+ # will be missing information, so let's guard.
+ # https://github.com/home-assistant/core/issues/24982
+ module_name = __name__
+ logger_func = logging.getLogger(module_name).error
+
+ def validator(config: dict) -> dict:
+ if domain in config and config[domain]:
+ logger_func(
+ (
+ "The %s integration does not support any configuration parameters, "
+ "got %s. Please remove the configuration parameters from your "
+ "configuration."
+ ),
+ domain,
+ config[domain],
+ )
+ return config
+
+ return validator
+
+
PLATFORM_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): string,
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 001713505941..ef526f956cd2 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -40,7 +40,10 @@ from homeassistant.util import dt as dt_util, ensure_unique_string, slugify
from . import device_registry as dr, entity_registry as er
from .device_registry import DeviceEntryType
-from .event import async_track_entity_registry_updated_event
+from .event import (
+ async_track_device_registry_updated_event,
+ async_track_entity_registry_updated_event,
+)
from .typing import StateType
if TYPE_CHECKING:
@@ -265,6 +268,8 @@ class Entity(ABC):
# Hold list for functions to call on remove.
_on_remove: list[CALLBACK_TYPE] | None = None
+ _unsub_device_updates: CALLBACK_TYPE | None = None
+
# Context
_context: Context | None = None
_context_set: datetime | None = None
@@ -770,8 +775,8 @@ class Entity(ABC):
await self.parallel_updates.acquire()
if warning:
- update_warn = hass.loop.call_later(
- SLOW_UPDATE_WARNING, self._async_slow_update_warning
+ update_warn = hass.loop.call_at(
+ hass.loop.time() + SLOW_UPDATE_WARNING, self._async_slow_update_warning
)
try:
@@ -926,6 +931,7 @@ class Entity(ABC):
self.hass, self.entity_id, self._async_registry_updated
)
)
+ self._async_subscribe_device_updates()
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
@@ -946,6 +952,9 @@ class Entity(ABC):
if data["action"] != "update":
return
+ if "device_id" in data["changes"]:
+ self._async_subscribe_device_updates()
+
ent_reg = er.async_get(self.hass)
old = self.registry_entry
self.registry_entry = ent_reg.async_get(data["entity_id"])
@@ -967,6 +976,51 @@ class Entity(ABC):
self.entity_id = self.registry_entry.entity_id
await self.platform.async_add_entities([self])
+ @callback
+ def _async_unsubscribe_device_updates(self) -> None:
+ """Unsubscribe from device registry updates."""
+ if not self._unsub_device_updates:
+ return
+ self._unsub_device_updates()
+ self._unsub_device_updates = None
+
+ @callback
+ def _async_subscribe_device_updates(self) -> None:
+ """Subscribe to device registry updates."""
+ assert self.registry_entry
+
+ self._async_unsubscribe_device_updates()
+
+ if (device_id := self.registry_entry.device_id) is None:
+ return
+
+ if not self.has_entity_name:
+ return
+
+ @callback
+ def async_device_registry_updated(event: Event) -> None:
+ """Handle device registry update."""
+ data = event.data
+
+ if data["action"] != "update":
+ return
+
+ if "name" not in data["changes"] and "name_by_user" not in data["changes"]:
+ return
+
+ self.async_write_ha_state()
+
+ self._unsub_device_updates = async_track_device_registry_updated_event(
+ self.hass,
+ device_id,
+ async_device_registry_updated,
+ )
+ if (
+ not self._on_remove
+ or self._async_unsubscribe_device_updates not in self._on_remove
+ ):
+ self.async_on_remove(self._async_unsubscribe_device_updates)
+
def __repr__(self) -> str:
"""Return the representation."""
return f""
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 0f93dca6939c..f7793a4329c4 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -44,6 +44,7 @@ from . import (
from .device_registry import DeviceRegistry
from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider
from .event import async_call_later, async_track_time_interval
+from .issue_registry import IssueSeverity, async_create_issue
from .typing import ConfigType, DiscoveryInfoType
if TYPE_CHECKING:
@@ -136,6 +137,7 @@ class EntityPlatform:
self._process_updates: asyncio.Lock | None = None
self.parallel_updates: asyncio.Semaphore | None = None
+ self._update_in_sequence: bool = False
# Platform is None for the EntityComponent "catch-all" EntityPlatform
# which powers entity_component.add_entities
@@ -186,6 +188,7 @@ class EntityPlatform:
if parallel_updates is not None:
self.parallel_updates = asyncio.Semaphore(parallel_updates)
+ self._update_in_sequence = parallel_updates == 1
return self.parallel_updates
@@ -209,6 +212,19 @@ class EntityPlatform:
self.platform_name,
self.domain,
)
+ async_create_issue(
+ self.hass,
+ self.domain,
+ f"platform_integration_no_support_{self.domain}_{self.platform_name}",
+ is_fixable=False,
+ severity=IssueSeverity.ERROR,
+ translation_key="platform_integration_no_support",
+ translation_placeholders={
+ "domain": self.domain,
+ "platform": self.platform_name,
+ },
+ )
+
return
@callback
@@ -288,8 +304,8 @@ class EntityPlatform:
)
logger.info("Setting up %s", full_name)
- warn_task = hass.loop.call_later(
- SLOW_SETUP_WARNING,
+ warn_task = hass.loop.call_at(
+ hass.loop.time() + SLOW_SETUP_WARNING,
logger.warning,
"Setup of %s platform %s is taking over %s seconds.",
self.domain,
@@ -613,10 +629,12 @@ class EntityPlatform:
except RequiredParameterMissing:
pass
- if entity.entity_id is not None:
+ # An entity may suggest the entity_id by setting entity_id itself
+ suggested_entity_id: str | None = entity.entity_id
+ if suggested_entity_id is not None:
suggested_object_id = split_entity_id(entity.entity_id)[1]
else:
- if device and entity.has_entity_name: # type: ignore[unreachable]
+ if device and entity.has_entity_name:
device_name = device.name_by_user or device.name
if not entity.name:
suggested_object_id = device_name
@@ -842,13 +860,22 @@ class EntityPlatform:
return
async with self._process_updates:
- tasks: list[Coroutine[Any, Any, None]] = []
- for entity in self.entities.values():
- if not entity.should_poll:
- continue
- tasks.append(entity.async_update_ha_state(True))
+ if self._update_in_sequence or len(self.entities) <= 1:
+ # If we know we will update sequentially, we want to avoid scheduling
+ # the coroutines as tasks that will wait on the semaphore lock.
+ for entity in list(self.entities.values()):
+ # If the entity is removed from hass during the previous
+ # entity being updated, we need to skip updating the
+ # entity.
+ if entity.should_poll and entity.hass:
+ await entity.async_update_ha_state(True)
+ return
- if tasks:
+ if tasks := [
+ entity.async_update_ha_state(True)
+ for entity in self.entities.values()
+ if entity.should_poll
+ ]:
await asyncio.gather(*tasks)
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index d8c5a6c1cf67..b6fc84b9627c 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -12,7 +12,6 @@ from __future__ import annotations
from collections import UserDict
from collections.abc import Callable, Iterable, Mapping, ValuesView
import logging
-from types import MappingProxyType
from typing import TYPE_CHECKING, Any, TypeVar, cast
import attr
@@ -44,6 +43,7 @@ from homeassistant.core import (
from homeassistant.exceptions import MaxLengthExceeded
from homeassistant.util import slugify, uuid as uuid_util
from homeassistant.util.json import format_unserializable_data
+from homeassistant.util.read_only_dict import ReadOnlyDict
from . import device_registry as dr, storage
from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED
@@ -102,6 +102,7 @@ class RegistryEntryHider(StrEnum):
EntityOptionsType = Mapping[str, Mapping[str, Any]]
+ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]]
DISLAY_DICT_OPTIONAL = (
("ai", "area_id"),
@@ -110,27 +111,13 @@ DISLAY_DICT_OPTIONAL = (
)
-class _EntityOptions(UserDict[str, MappingProxyType]):
- """Container for entity options."""
-
- def __init__(self, data: Mapping[str, Mapping] | None) -> None:
- """Initialize."""
- super().__init__()
- if data is None:
- return
- self.data = {key: MappingProxyType(val) for key, val in data.items()}
-
- def __setitem__(self, key: str, entry: Mapping) -> None:
- """Add an item."""
- raise NotImplementedError
-
- def __delitem__(self, key: str) -> None:
- """Remove an item."""
- raise NotImplementedError
-
- def as_dict(self) -> dict[str, dict]:
- """Return dictionary version."""
- return {key: dict(val) for key, val in self.data.items()}
+def _protect_entity_options(
+ data: EntityOptionsType | None,
+) -> ReadOnlyEntityOptionsType:
+ """Protect entity options from being modified."""
+ if data is None:
+ return ReadOnlyDict({})
+ return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()})
@attr.s(slots=True, frozen=True)
@@ -154,7 +141,9 @@ class RegistryEntry:
id: str = attr.ib(factory=uuid_util.random_uuid_hex)
has_entity_name: bool = attr.ib(default=False)
name: str | None = attr.ib(default=None)
- options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions)
+ options: ReadOnlyEntityOptionsType = attr.ib(
+ default=None, converter=_protect_entity_options
+ )
# As set by integration
original_device_class: str | None = attr.ib(default=None)
original_icon: str | None = attr.ib(default=None)
@@ -1029,7 +1018,7 @@ class EntityRegistry:
"id": entry.id,
"has_entity_name": entry.has_entity_name,
"name": entry.name,
- "options": entry.options.as_dict(),
+ "options": entry.options,
"original_device_class": entry.original_device_class,
"original_icon": entry.original_icon,
"original_name": entry.original_name,
diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py
index 057e8f0955e3..a9d3ccad138f 100644
--- a/homeassistant/helpers/entityfilter.py
+++ b/homeassistant/helpers/entityfilter.py
@@ -35,7 +35,14 @@ class EntityFilter:
self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS])
self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS])
self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS])
- self._filter: Callable[[str], bool] | None = None
+ self._filter = _generate_filter_from_sets_and_pattern_lists(
+ self._include_d,
+ self._include_e,
+ self._exclude_d,
+ self._exclude_e,
+ self._include_eg,
+ self._exclude_eg,
+ )
def explicitly_included(self, entity_id: str) -> bool:
"""Check if an entity is explicitly included."""
@@ -49,17 +56,12 @@ class EntityFilter:
bool(self._exclude_eg and self._exclude_eg.match(entity_id))
)
+ def get_filter(self) -> Callable[[str], bool]:
+ """Return the filter function."""
+ return self._filter
+
def __call__(self, entity_id: str) -> bool:
"""Run the filter."""
- if self._filter is None:
- self._filter = _generate_filter_from_sets_and_pattern_lists(
- self._include_d,
- self._include_e,
- self._exclude_d,
- self._exclude_e,
- self._include_eg,
- self._exclude_eg,
- )
return self._filter(entity_id)
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 0a51d6660ae8..c7e34ac2eda5 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -36,6 +36,7 @@ from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
+from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from .entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from .ratelimit import KeyedRateLimit
from .sun import get_astral_event_next
@@ -54,6 +55,9 @@ TRACK_STATE_REMOVED_DOMAIN_LISTENER = "track_state_removed_domain_listener"
TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks"
TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener"
+TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS = "track_device_registry_updated_callbacks"
+TRACK_DEVICE_REGISTRY_UPDATED_LISTENER = "track_device_registry_updated_listener"
+
_ALL_LISTENER = "all"
_DOMAINS_LISTENER = "domains"
_ENTITIES_LISTENER = "entities"
@@ -256,6 +260,34 @@ def async_track_state_change_event(
return _async_track_state_change_event(hass, entity_ids, action)
+@callback
+def _async_dispatch_entity_id_event(
+ hass: HomeAssistant,
+ callbacks: dict[str, list[HassJob[[Event], Any]]],
+ event: Event,
+) -> None:
+ """Dispatch to listeners."""
+ if not (callbacks_list := callbacks.get(event.data["entity_id"])):
+ return
+ for job in callbacks_list[:]:
+ try:
+ hass.async_run_hass_job(job, event)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error while dispatching event for %s to %s",
+ event.data["entity_id"],
+ job,
+ )
+
+
+@callback
+def _async_state_change_filter(
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
+) -> bool:
+ """Filter state changes by entity_id."""
+ return event.data["entity_id"] in callbacks
+
+
@bind_hass
def _async_track_state_change_event(
hass: HomeAssistant,
@@ -263,57 +295,17 @@ def _async_track_state_change_event(
action: Callable[[Event], Any],
) -> CALLBACK_TYPE:
"""async_track_state_change_event without lowercasing."""
- entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault(
- TRACK_STATE_CHANGE_CALLBACKS, {}
+ return _async_track_event(
+ hass,
+ entity_ids,
+ TRACK_STATE_CHANGE_CALLBACKS,
+ TRACK_STATE_CHANGE_LISTENER,
+ EVENT_STATE_CHANGED,
+ _async_dispatch_entity_id_event,
+ _async_state_change_filter,
+ action,
)
- if TRACK_STATE_CHANGE_LISTENER not in hass.data:
-
- @callback
- def _async_state_change_filter(event: Event) -> bool:
- """Filter state changes by entity_id."""
- return event.data.get("entity_id") in entity_callbacks
-
- @callback
- def _async_state_change_dispatcher(event: Event) -> None:
- """Dispatch state changes by entity_id."""
- entity_id = event.data.get("entity_id")
-
- if entity_id not in entity_callbacks:
- return
-
- for job in entity_callbacks[entity_id][:]:
- try:
- hass.async_run_hass_job(job, event)
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception(
- "Error while processing state change for %s", entity_id
- )
-
- hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen(
- EVENT_STATE_CHANGED,
- _async_state_change_dispatcher,
- event_filter=_async_state_change_filter,
- )
-
- job = HassJob(action, f"track state change event {entity_ids}")
-
- for entity_id in entity_ids:
- entity_callbacks.setdefault(entity_id, []).append(job)
-
- @callback
- def remove_listener() -> None:
- """Remove state change listener."""
- _async_remove_indexed_listeners(
- hass,
- TRACK_STATE_CHANGE_CALLBACKS,
- TRACK_STATE_CHANGE_LISTENER,
- entity_ids,
- job,
- )
-
- return remove_listener
-
@callback
def _remove_empty_listener() -> None:
@@ -321,24 +313,102 @@ def _remove_empty_listener() -> None:
@callback
-def _async_remove_indexed_listeners(
+def _remove_listener(
hass: HomeAssistant,
- data_key: str,
- listener_key: str,
- storage_keys: Iterable[str],
+ listeners_key: str,
+ keys: Iterable[str],
job: HassJob[[Event], Any],
+ callbacks: dict[str, list[HassJob[[Event], Any]]],
) -> None:
- """Remove a listener."""
- callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data[data_key]
-
- for storage_key in storage_keys:
- callbacks[storage_key].remove(job)
- if len(callbacks[storage_key]) == 0:
- del callbacks[storage_key]
+ """Remove listener."""
+ for key in keys:
+ callbacks[key].remove(job)
+ if len(callbacks[key]) == 0:
+ del callbacks[key]
if not callbacks:
- hass.data[listener_key]()
- del hass.data[listener_key]
+ hass.data[listeners_key]()
+ del hass.data[listeners_key]
+
+
+def _async_track_event(
+ hass: HomeAssistant,
+ keys: str | Iterable[str],
+ callbacks_key: str,
+ listeners_key: str,
+ event_type: str,
+ dispatcher_callable: Callable[
+ [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], None
+ ],
+ filter_callable: Callable[
+ [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], bool
+ ],
+ action: Callable[[Event], None],
+) -> CALLBACK_TYPE:
+ """Track an event by a specific key."""
+ if not keys:
+ return _remove_empty_listener
+
+ if isinstance(keys, str):
+ keys = [keys]
+
+ hass_data = hass.data
+
+ callbacks: dict[str, list[HassJob[[Event], Any]]] | None = hass_data.get(
+ callbacks_key
+ )
+ if not callbacks:
+ callbacks = hass_data[callbacks_key] = {}
+
+ if listeners_key not in hass_data:
+ hass_data[listeners_key] = hass.bus.async_listen(
+ event_type,
+ callback(ft.partial(dispatcher_callable, hass, callbacks)),
+ event_filter=callback(ft.partial(filter_callable, hass, callbacks)),
+ )
+
+ job = HassJob(action, f"track {event_type} event {keys}")
+
+ for key in keys:
+ callback_list = callbacks.get(key)
+ if callback_list:
+ callback_list.append(job)
+ else:
+ callbacks[key] = [job]
+
+ return ft.partial(_remove_listener, hass, listeners_key, keys, job, callbacks)
+
+
+@callback
+def _async_dispatch_old_entity_id_or_entity_id_event(
+ hass: HomeAssistant,
+ callbacks: dict[str, list[HassJob[[Event], Any]]],
+ event: Event,
+) -> None:
+ """Dispatch to listeners."""
+ if not (
+ callbacks_list := callbacks.get(
+ event.data.get("old_entity_id", event.data["entity_id"])
+ )
+ ):
+ return
+ for job in callbacks_list[:]:
+ try:
+ hass.async_run_hass_job(job, event)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error while dispatching event for %s to %s",
+ event.data.get("old_entity_id", event.data["entity_id"]),
+ job,
+ )
+
+
+@callback
+def _async_entity_registry_updated_filter(
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
+) -> bool:
+ """Filter entity registry updates by entity_id."""
+ return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks
@bind_hass
@@ -353,76 +423,70 @@ def async_track_entity_registry_updated_event(
Similar to async_track_state_change_event.
"""
- if not entity_ids:
- return _remove_empty_listener
- if isinstance(entity_ids, str):
- entity_ids = [entity_ids]
-
- entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault(
- TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}
+ return _async_track_event(
+ hass,
+ entity_ids,
+ TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS,
+ TRACK_ENTITY_REGISTRY_UPDATED_LISTENER,
+ EVENT_ENTITY_REGISTRY_UPDATED,
+ _async_dispatch_old_entity_id_or_entity_id_event,
+ _async_entity_registry_updated_filter,
+ action,
)
- if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data:
-
- @callback
- def _async_entity_registry_updated_filter(event: Event) -> bool:
- """Filter entity registry updates by entity_id."""
- entity_id = event.data.get("old_entity_id", event.data["entity_id"])
- return entity_id in entity_callbacks
-
- @callback
- def _async_entity_registry_updated_dispatcher(event: Event) -> None:
- """Dispatch entity registry updates by entity_id."""
- entity_id = event.data.get("old_entity_id", event.data["entity_id"])
-
- if entity_id not in entity_callbacks:
- return
-
- for job in entity_callbacks[entity_id][:]:
- try:
- hass.async_run_hass_job(job, event)
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception(
- "Error while processing entity registry update for %s",
- entity_id,
- )
-
- hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen(
- EVENT_ENTITY_REGISTRY_UPDATED,
- _async_entity_registry_updated_dispatcher,
- event_filter=_async_entity_registry_updated_filter,
- )
-
- job = HassJob(action, f"track entity registry updated event {entity_ids}")
-
- for entity_id in entity_ids:
- entity_callbacks.setdefault(entity_id, []).append(job)
-
- @callback
- def remove_listener() -> None:
- """Remove state change listener."""
- _async_remove_indexed_listeners(
- hass,
- TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS,
- TRACK_ENTITY_REGISTRY_UPDATED_LISTENER,
- entity_ids,
- job,
- )
-
- return remove_listener
-
@callback
-def _async_domain_has_listeners(
- domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]]
+def _async_device_registry_updated_filter(
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
) -> bool:
- """Check if the domain has any listeners."""
- return domain in callbacks or MATCH_ALL in callbacks
+ """Filter device registry updates by device_id."""
+ return event.data["device_id"] in callbacks
+
+
+@callback
+def _async_dispatch_device_id_event(
+ hass: HomeAssistant,
+ callbacks: dict[str, list[HassJob[[Event], Any]]],
+ event: Event,
+) -> None:
+ """Dispatch to listeners."""
+ if not (callbacks_list := callbacks.get(event.data["device_id"])):
+ return
+ for job in callbacks_list[:]:
+ try:
+ hass.async_run_hass_job(job, event)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error while dispatching event for %s to %s",
+ event.data["device_id"],
+ job,
+ )
+
+
+def async_track_device_registry_updated_event(
+ hass: HomeAssistant,
+ device_ids: str | Iterable[str],
+ action: Callable[[Event], Any],
+) -> CALLBACK_TYPE:
+ """Track specific device registry updated events indexed by device_id.
+
+ Similar to async_track_entity_registry_updated_event.
+ """
+ return _async_track_event(
+ hass,
+ device_ids,
+ TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS,
+ TRACK_DEVICE_REGISTRY_UPDATED_LISTENER,
+ EVENT_DEVICE_REGISTRY_UPDATED,
+ _async_dispatch_device_id_event,
+ _async_device_registry_updated_filter,
+ action,
+ )
@callback
def _async_dispatch_domain_event(
- hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]]
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
) -> None:
"""Dispatch domain event listeners."""
domain = split_entity_id(event.data["entity_id"])[0]
@@ -435,6 +499,17 @@ def _async_dispatch_domain_event(
)
+@callback
+def _async_domain_added_filter(
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
+) -> bool:
+ """Filter state changes by entity_id."""
+ return event.data.get("old_state") is None and (
+ MATCH_ALL in callbacks
+ or split_entity_id(event.data["entity_id"])[0] in callbacks
+ )
+
+
@bind_hass
def async_track_state_added_domain(
hass: HomeAssistant,
@@ -453,48 +528,28 @@ def _async_track_state_added_domain(
domains: str | Iterable[str],
action: Callable[[Event], Any],
) -> CALLBACK_TYPE:
- """async_track_state_added_domain without lowercasing."""
- domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault(
- TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {}
+ """Track state change events when an entity is added to domains."""
+ return _async_track_event(
+ hass,
+ domains,
+ TRACK_STATE_ADDED_DOMAIN_CALLBACKS,
+ TRACK_STATE_ADDED_DOMAIN_LISTENER,
+ EVENT_STATE_CHANGED,
+ _async_dispatch_domain_event,
+ _async_domain_added_filter,
+ action,
)
- if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data:
- @callback
- def _async_state_change_filter(event: Event) -> bool:
- """Filter state changes by entity_id."""
- return event.data.get("old_state") is None and _async_domain_has_listeners(
- split_entity_id(event.data["entity_id"])[0], domain_callbacks
- )
-
- @callback
- def _async_state_change_dispatcher(event: Event) -> None:
- """Dispatch state changes by entity_id."""
- _async_dispatch_domain_event(hass, event, domain_callbacks)
-
- hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen(
- EVENT_STATE_CHANGED,
- _async_state_change_dispatcher,
- event_filter=_async_state_change_filter,
- )
-
- job = HassJob(action, f"track state added domain event {domains}")
-
- for domain in domains:
- domain_callbacks.setdefault(domain, []).append(job)
-
- @callback
- def remove_listener() -> None:
- """Remove state change listener."""
- _async_remove_indexed_listeners(
- hass,
- TRACK_STATE_ADDED_DOMAIN_CALLBACKS,
- TRACK_STATE_ADDED_DOMAIN_LISTENER,
- domains,
- job,
- )
-
- return remove_listener
+@callback
+def _async_domain_removed_filter(
+ hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event
+) -> bool:
+ """Filter state changes by entity_id."""
+ return event.data.get("new_state") is None and (
+ MATCH_ALL in callbacks
+ or split_entity_id(event.data["entity_id"])[0] in callbacks
+ )
@bind_hass
@@ -504,51 +559,17 @@ def async_track_state_removed_domain(
action: Callable[[Event], Any],
) -> CALLBACK_TYPE:
"""Track state change events when an entity is removed from domains."""
- if not (domains := _async_string_to_lower_list(domains)):
- return _remove_empty_listener
-
- domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault(
- TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}
+ return _async_track_event(
+ hass,
+ domains,
+ TRACK_STATE_REMOVED_DOMAIN_CALLBACKS,
+ TRACK_STATE_REMOVED_DOMAIN_LISTENER,
+ EVENT_STATE_CHANGED,
+ _async_dispatch_domain_event,
+ _async_domain_removed_filter,
+ action,
)
- if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data:
-
- @callback
- def _async_state_change_filter(event: Event) -> bool:
- """Filter state changes by entity_id."""
- return event.data.get("new_state") is None and _async_domain_has_listeners(
- split_entity_id(event.data["entity_id"])[0], domain_callbacks
- )
-
- @callback
- def _async_state_change_dispatcher(event: Event) -> None:
- """Dispatch state changes by entity_id."""
- _async_dispatch_domain_event(hass, event, domain_callbacks)
-
- hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen(
- EVENT_STATE_CHANGED,
- _async_state_change_dispatcher,
- event_filter=_async_state_change_filter,
- )
-
- job = HassJob(action, f"track state removed domain event {domains}")
-
- for domain in domains:
- domain_callbacks.setdefault(domain, []).append(job)
-
- @callback
- def remove_listener() -> None:
- """Remove state change listener."""
- _async_remove_indexed_listeners(
- hass,
- TRACK_STATE_REMOVED_DOMAIN_CALLBACKS,
- TRACK_STATE_REMOVED_DOMAIN_LISTENER,
- domains,
- job,
- )
-
- return remove_listener
-
@callback
def _async_string_to_lower_list(instr: str | Iterable[str]) -> list[str]:
@@ -1322,6 +1343,7 @@ def async_track_point_in_utc_time(
# Since this is called once, we accept a HassJob so we can avoid
# having to figure out how to call the action every time its called.
cancel_callback: asyncio.TimerHandle | None = None
+ loop = hass.loop
@callback
def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None:
@@ -1335,7 +1357,7 @@ def async_track_point_in_utc_time(
if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0:
_LOGGER.debug("Called %f seconds too early, rearming", delta)
- cancel_callback = hass.loop.call_later(delta, run_action, job)
+ cancel_callback = loop.call_at(loop.time() + delta, run_action, job)
return
hass.async_run_hass_job(job, utc_point_in_time)
@@ -1346,11 +1368,11 @@ def async_track_point_in_utc_time(
else HassJob(action, f"track point in utc time {utc_point_in_time}")
)
delta = expected_fire_timestamp - time.time()
- cancel_callback = hass.loop.call_later(delta, run_action, job)
+ cancel_callback = loop.call_at(loop.time() + delta, run_action, job)
@callback
def unsub_point_in_time_listener() -> None:
- """Cancel the call_later."""
+ """Cancel the call_at."""
assert cancel_callback is not None
cancel_callback.cancel()
@@ -1382,7 +1404,7 @@ def async_call_later(
if isinstance(action, HassJob)
else HassJob(action, f"call_later {delay}")
)
- cancel_callback = hass.loop.call_later(delay, run_action, job)
+ cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job)
@callback
def unsub_call_later_listener() -> None:
@@ -1603,7 +1625,7 @@ def async_track_utc_time_change(
pattern_time_change_listener_job = HassJob(
pattern_time_change_listener,
- "time change listener {hour}:{minute}:{second} {action}",
+ f"time change listener {hour}:{minute}:{second} {action}",
)
time_listener = async_track_point_in_utc_time(
hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow())
diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py
index c15436ed2c1e..38c230508851 100644
--- a/homeassistant/helpers/json.py
+++ b/homeassistant/helpers/json.py
@@ -2,14 +2,14 @@
from collections import deque
from collections.abc import Callable
import datetime
+from functools import partial
import json
import logging
from pathlib import Path
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
import orjson
-from homeassistant.core import Event, State
from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic
from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401
JSON_DECODE_EXCEPTIONS,
@@ -56,6 +56,18 @@ def json_encoder_default(obj: Any) -> Any:
raise TypeError
+if TYPE_CHECKING:
+
+ def json_bytes(obj: Any) -> bytes:
+ """Dump json bytes."""
+
+else:
+ json_bytes = partial(
+ orjson.dumps, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default
+ )
+ """Dump json bytes."""
+
+
class ExtendedJSONEncoder(JSONEncoder):
"""JSONEncoder that supports Home Assistant objects and falls back to repr(o)."""
@@ -76,13 +88,6 @@ class ExtendedJSONEncoder(JSONEncoder):
return {"__type": str(type(o)), "repr": repr(o)}
-def json_bytes(data: Any) -> bytes:
- """Dump json bytes."""
- return orjson.dumps(
- data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default
- )
-
-
def _strip_null(obj: Any) -> Any:
"""Strip NUL from an object."""
if isinstance(obj, str):
@@ -120,9 +125,7 @@ def json_dumps(data: Any) -> str:
with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it
will fallback to as_dict
"""
- return orjson.dumps(
- data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default
- ).decode("utf-8")
+ return json_bytes(data).decode("utf-8")
def json_dumps_sorted(data: Any) -> str:
@@ -189,6 +192,11 @@ def find_paths_unserializable_data(
This method is slow! Only use for error handling.
"""
+ from homeassistant.core import ( # pylint: disable=import-outside-toplevel
+ Event,
+ State,
+ )
+
to_process = deque([(bad_data, "$")])
invalid = {}
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
index d31c12d0fd52..ab3b93cf3c47 100644
--- a/homeassistant/helpers/restore_state.py
+++ b/homeassistant/helpers/restore_state.py
@@ -2,7 +2,6 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-import asyncio
from datetime import datetime, timedelta
import logging
from typing import Any, cast
@@ -17,11 +16,11 @@ import homeassistant.util.dt as dt_util
from . import start
from .entity import Entity
from .event import async_track_time_interval
+from .frame import report
from .json import JSONEncoder
-from .singleton import singleton
from .storage import Store
-DATA_RESTORE_STATE_TASK = "restore_state_task"
+DATA_RESTORE_STATE = "restore_state"
_LOGGER = logging.getLogger(__name__)
@@ -96,45 +95,41 @@ class StoredState:
)
+async def async_load(hass: HomeAssistant) -> None:
+ """Load the restore state task."""
+ restore_state = RestoreStateData(hass)
+ await restore_state.async_setup()
+ hass.data[DATA_RESTORE_STATE] = restore_state
+
+
+@callback
+def async_get(hass: HomeAssistant) -> RestoreStateData:
+ """Get the restore state data helper."""
+ return cast(RestoreStateData, hass.data[DATA_RESTORE_STATE])
+
+
class RestoreStateData:
"""Helper class for managing the helper saved data."""
- @staticmethod
- @singleton(DATA_RESTORE_STATE_TASK)
- async def async_get_instance(hass: HomeAssistant) -> RestoreStateData:
- """Get the singleton instance of this data helper."""
- data = RestoreStateData(hass)
-
- try:
- stored_states = await data.store.async_load()
- except HomeAssistantError as exc:
- _LOGGER.error("Error loading last states", exc_info=exc)
- stored_states = None
-
- if stored_states is None:
- _LOGGER.debug("Not creating cache - no saved states found")
- data.last_states = {}
- else:
- data.last_states = {
- item["state"]["entity_id"]: StoredState.from_dict(item)
- for item in stored_states
- if valid_entity_id(item["state"]["entity_id"])
- }
- _LOGGER.debug("Created cache with %s", list(data.last_states))
-
- async def hass_start(hass: HomeAssistant) -> None:
- """Start the restore state task."""
- data.async_setup_dump()
-
- start.async_at_start(hass, hass_start)
-
- return data
-
@classmethod
async def async_save_persistent_states(cls, hass: HomeAssistant) -> None:
"""Dump states now."""
- data = await cls.async_get_instance(hass)
- await data.async_dump_states()
+ await async_get(hass).async_dump_states()
+
+ @classmethod
+ async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData:
+ """Return the instance of this class."""
+ # Nothing should actually be calling this anymore, but we'll keep it
+ # around for a while to avoid breaking custom components.
+ #
+ # In fact they should not be accessing this at all.
+ report(
+ "restore_state.RestoreStateData.async_get_instance is deprecated, "
+ "and not intended to be called by custom components; Please"
+ "refactor your code to use RestoreEntity instead;"
+ " restore_state.async_get(hass) can be used in the meantime",
+ )
+ return async_get(hass)
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the restore state data class."""
@@ -145,6 +140,35 @@ class RestoreStateData:
self.last_states: dict[str, StoredState] = {}
self.entities: dict[str, RestoreEntity] = {}
+ async def async_setup(self) -> None:
+ """Set up up the instance of this data helper."""
+ await self.async_load()
+
+ async def hass_start(hass: HomeAssistant) -> None:
+ """Start the restore state task."""
+ self.async_setup_dump()
+
+ start.async_at_start(self.hass, hass_start)
+
+ async def async_load(self) -> None:
+ """Load the instance of this data helper."""
+ try:
+ stored_states = await self.store.async_load()
+ except HomeAssistantError as exc:
+ _LOGGER.error("Error loading last states", exc_info=exc)
+ stored_states = None
+
+ if stored_states is None:
+ _LOGGER.debug("Not creating cache - no saved states found")
+ self.last_states = {}
+ else:
+ self.last_states = {
+ item["state"]["entity_id"]: StoredState.from_dict(item)
+ for item in stored_states
+ if valid_entity_id(item["state"]["entity_id"])
+ }
+ _LOGGER.debug("Created cache with %s", list(self.last_states))
+
@callback
def async_get_stored_states(self) -> list[StoredState]:
"""Get the set of states which should be stored.
@@ -288,21 +312,18 @@ class RestoreEntity(Entity):
async def async_internal_added_to_hass(self) -> None:
"""Register this entity as a restorable entity."""
- _, data = await asyncio.gather(
- super().async_internal_added_to_hass(),
- RestoreStateData.async_get_instance(self.hass),
- )
- data.async_restore_entity_added(self)
+ await super().async_internal_added_to_hass()
+ async_get(self.hass).async_restore_entity_added(self)
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
- _, data = await asyncio.gather(
- super().async_internal_will_remove_from_hass(),
- RestoreStateData.async_get_instance(self.hass),
+ async_get(self.hass).async_restore_entity_removed(
+ self.entity_id, self.extra_restore_state_data
)
- data.async_restore_entity_removed(self.entity_id, self.extra_restore_state_data)
+ await super().async_internal_will_remove_from_hass()
- async def _async_get_restored_data(self) -> StoredState | None:
+ @callback
+ def _async_get_restored_data(self) -> StoredState | None:
"""Get data stored for an entity, if any."""
if self.hass is None or self.entity_id is None:
# Return None if this entity isn't added to hass yet
@@ -310,20 +331,17 @@ class RestoreEntity(Entity):
"Cannot get last state. Entity not added to hass"
)
return None
- data = await RestoreStateData.async_get_instance(self.hass)
- if self.entity_id not in data.last_states:
- return None
- return data.last_states[self.entity_id]
+ return async_get(self.hass).last_states.get(self.entity_id)
async def async_get_last_state(self) -> State | None:
"""Get the entity state from the previous run."""
- if (stored_state := await self._async_get_restored_data()) is None:
+ if (stored_state := self._async_get_restored_data()) is None:
return None
return stored_state.state
async def async_get_last_extra_data(self) -> ExtraStoredData | None:
"""Get the entity specific state data from the previous run."""
- if (stored_state := await self._async_get_restored_data()) is None:
+ if (stored_state := self._async_get_restored_data()) is None:
return None
return stored_state.extra_data
diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py
index fec9d25563e7..2e7df07cf048 100644
--- a/homeassistant/helpers/selector.py
+++ b/homeassistant/helpers/selector.py
@@ -340,6 +340,28 @@ class AttributeSelector(Selector[AttributeSelectorConfig]):
return attribute
+class BackupLocationSelectorConfig(TypedDict, total=False):
+ """Class to represent a backup location selector config."""
+
+
+@SELECTORS.register("backup_location")
+class BackupLocationSelector(Selector[BackupLocationSelectorConfig]):
+ """Selector of a backup location."""
+
+ selector_type = "backup_location"
+
+ CONFIG_SCHEMA = vol.Schema({})
+
+ def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None:
+ """Instantiate a selector."""
+ super().__init__(config)
+
+ def __call__(self, data: Any) -> str:
+ """Validate the passed selection."""
+ name: str = vol.Match(r"^(?:\/backup|\w+)$")(data)
+ return name
+
+
class BooleanSelectorConfig(TypedDict):
"""Class to represent a boolean selector config."""
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 14cf6a85a244..a9d7b906e733 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -59,6 +59,7 @@ CONF_SERVICE_ENTITY_ID = "entity_id"
_LOGGER = logging.getLogger(__name__)
SERVICE_DESCRIPTION_CACHE = "service_description_cache"
+ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache"
@cache
@@ -559,17 +560,27 @@ async def async_get_all_descriptions(
) -> dict[str, dict[str, Any]]:
"""Return descriptions (i.e. user documentation) for all service calls."""
descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {})
- format_cache_key = "{}.{}".format
services = hass.services.async_services()
# See if there are new services not seen before.
# Any service that we saw before already has an entry in description_cache.
missing = set()
+ all_services = []
for domain in services:
for service in services[domain]:
- if format_cache_key(domain, service) not in descriptions_cache:
+ cache_key = (domain, service)
+ all_services.append(cache_key)
+ if cache_key not in descriptions_cache:
missing.add(domain)
- break
+
+ # If we have a complete cache, check if it is still valid
+ if ALL_SERVICE_DESCRIPTIONS_CACHE in hass.data:
+ previous_all_services, previous_descriptions_cache = hass.data[
+ ALL_SERVICE_DESCRIPTIONS_CACHE
+ ]
+ # If the services are the same, we can return the cache
+ if previous_all_services == all_services:
+ return cast(dict[str, dict[str, Any]], previous_descriptions_cache)
# Files we loaded for missing descriptions
loaded = {}
@@ -595,7 +606,7 @@ async def async_get_all_descriptions(
descriptions[domain] = {}
for service in services[domain]:
- cache_key = format_cache_key(domain, service)
+ cache_key = (domain, service)
description = descriptions_cache.get(cache_key)
# Cache missing descriptions
@@ -622,6 +633,7 @@ async def async_get_all_descriptions(
descriptions[domain][service] = description
+ hass.data[ALL_SERVICE_DESCRIPTIONS_CACHE] = (all_services, descriptions)
return descriptions
@@ -652,7 +664,8 @@ def async_set_service_schema(
if "target" in schema:
description["target"] = schema["target"]
- hass.data[SERVICE_DESCRIPTION_CACHE][f"{domain}.{service}"] = description
+ hass.data.pop(ALL_SERVICE_DESCRIPTIONS_CACHE, None)
+ hass.data[SERVICE_DESCRIPTION_CACHE][(domain, service)] = description
@bind_hass
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 5cc2c6aa8077..d40a0289ab8c 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -63,6 +63,7 @@ from homeassistant.core import (
State,
callback,
split_entity_id,
+ valid_domain,
valid_entity_id,
)
from homeassistant.exceptions import TemplateError
@@ -88,7 +89,6 @@ _LOGGER = logging.getLogger(__name__)
_SENTINEL = object()
DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S"
-_RENDER_INFO = "template.render_info"
_ENVIRONMENT = "template.environment"
_ENVIRONMENT_LIMITED = "template.environment_limited"
_ENVIRONMENT_STRICT = "template.environment_strict"
@@ -121,6 +121,9 @@ _P = ParamSpec("_P")
ALL_STATES_RATE_LIMIT = timedelta(minutes=1)
DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1)
+_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None)
+
+
template_cv: ContextVar[tuple[str, str] | None] = ContextVar(
"template_cv", default=None
)
@@ -326,6 +329,22 @@ _cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval)
class RenderInfo:
"""Holds information about a template render."""
+ __slots__ = (
+ "template",
+ "filter_lifecycle",
+ "filter",
+ "_result",
+ "is_static",
+ "exception",
+ "all_states",
+ "all_states_lifecycle",
+ "domains",
+ "domains_lifecycle",
+ "entities",
+ "rate_limit",
+ "has_time",
+ )
+
def __init__(self, template: Template) -> None:
"""Initialise."""
self.template = template
@@ -650,7 +669,7 @@ class Template:
) -> RenderInfo:
"""Render the template and collect an entity filter."""
self._renders += 1
- assert self.hass and _RENDER_INFO not in self.hass.data
+ assert self.hass and _render_info.get() is None
render_info = RenderInfo(self)
@@ -660,13 +679,13 @@ class Template:
render_info._freeze_static()
return render_info
- self.hass.data[_RENDER_INFO] = render_info
+ token = _render_info.set(render_info)
try:
render_info._result = self.async_render(variables, strict=strict, **kwargs)
except TemplateError as ex:
render_info.exception = ex
finally:
- del self.hass.data[_RENDER_INFO]
+ _render_info.reset(token)
render_info._freeze()
return render_info
@@ -796,7 +815,7 @@ class AllStates:
if name in _RESERVED_NAMES:
return None
- if not valid_entity_id(f"{name}.entity"):
+ if not valid_domain(name):
raise TemplateError(f"Invalid domain name '{name}'")
return _domain_states(self._hass, name)
@@ -806,13 +825,11 @@ class AllStates:
__getitem__ = __getattr__
def _collect_all(self) -> None:
- render_info = self._hass.data.get(_RENDER_INFO)
- if render_info is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.all_states = True
def _collect_all_lifecycle(self) -> None:
- render_info = self._hass.data.get(_RENDER_INFO)
- if render_info is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.all_states_lifecycle = True
def __iter__(self) -> Generator[TemplateState, None, None]:
@@ -868,14 +885,12 @@ class DomainStates:
__getitem__ = __getattr__
def _collect_domain(self) -> None:
- entity_collect = self._hass.data.get(_RENDER_INFO)
- if entity_collect is not None:
- entity_collect.domains.add(self._domain)
+ if (entity_collect := _render_info.get()) is not None:
+ entity_collect.domains.add(self._domain) # type: ignore[attr-defined]
def _collect_domain_lifecycle(self) -> None:
- entity_collect = self._hass.data.get(_RENDER_INFO)
- if entity_collect is not None:
- entity_collect.domains_lifecycle.add(self._domain)
+ if (entity_collect := _render_info.get()) is not None:
+ entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined]
def __iter__(self) -> Generator[TemplateState, None, None]:
"""Return the iteration over all the states."""
@@ -912,8 +927,8 @@ class TemplateStateBase(State):
self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None
def _collect_state(self) -> None:
- if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)):
- _render_info.entities.add(self._entity_id)
+ if self._collect and (render_info := _render_info.get()):
+ render_info.entities.add(self._entity_id) # type: ignore[attr-defined]
# Jinja will try __getitem__ first and it avoids the need
# to call is_safe_attribute
@@ -921,8 +936,8 @@ class TemplateStateBase(State):
"""Return a property as an attribute for jinja."""
if item in _COLLECTABLE_STATE_ATTRIBUTES:
# _collect_state inlined here for performance
- if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)):
- _render_info.entities.add(self._entity_id)
+ if self._collect and (render_info := _render_info.get()):
+ render_info.entities.add(self._entity_id) # type: ignore[attr-defined]
return getattr(self._state, item)
if item == "entity_id":
return self._entity_id
@@ -1056,8 +1071,8 @@ _create_template_state_no_collect = partial(TemplateState, collect=False)
def _collect_state(hass: HomeAssistant, entity_id: str) -> None:
- if (entity_collect := hass.data.get(_RENDER_INFO)) is not None:
- entity_collect.entities.add(entity_id)
+ if (entity_collect := _render_info.get()) is not None:
+ entity_collect.entities.add(entity_id) # type: ignore[attr-defined]
def _state_generator(
@@ -1574,7 +1589,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool:
def now(hass: HomeAssistant) -> datetime:
"""Record fetching now."""
- if (render_info := hass.data.get(_RENDER_INFO)) is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.has_time = True
return dt_util.now()
@@ -1582,7 +1597,7 @@ def now(hass: HomeAssistant) -> datetime:
def utcnow(hass: HomeAssistant) -> datetime:
"""Record fetching utcnow."""
- if (render_info := hass.data.get(_RENDER_INFO)) is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.has_time = True
return dt_util.utcnow()
@@ -2080,7 +2095,7 @@ def random_every_time(context, values):
def today_at(hass: HomeAssistant, time_str: str = "") -> datetime:
"""Record fetching now where the time has been replaced with value."""
- if (render_info := hass.data.get(_RENDER_INFO)) is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.has_time = True
today = dt_util.start_of_local_day()
@@ -2105,7 +2120,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any:
If the input are not a datetime object the input will be returned unmodified.
"""
- if (render_info := hass.data.get(_RENDER_INFO)) is not None:
+ if (render_info := _render_info.get()) is not None:
render_info.has_time = True
if not isinstance(value, datetime):
diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py
index b6b39e9c32fa..c4eb8a1343d3 100644
--- a/homeassistant/helpers/template_entity.py
+++ b/homeassistant/helpers/template_entity.py
@@ -29,6 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback
from homeassistant.exceptions import TemplateError
+from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from . import config_validation as cv
from .entity import Entity
@@ -487,9 +488,8 @@ class TriggerBaseEntity(Entity):
CONF_NAME,
CONF_PICTURE,
):
- if itm not in config:
+ if itm not in config or config[itm] is None:
continue
-
if config[itm].is_static:
self._static_rendered[itm] = config[itm].template
else:
@@ -597,3 +597,36 @@ class TriggerBaseEntity(Entity):
"Error rendering %s template for %s: %s", key, self.entity_id, err
)
self._rendered = self._static_rendered
+
+
+class ManualTriggerEntity(TriggerBaseEntity):
+ """Template entity based on manual trigger data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config: dict,
+ ) -> None:
+ """Initialize the entity."""
+ TriggerBaseEntity.__init__(self, hass, config)
+
+ @callback
+ def _process_manual_data(self, value: str | None = None) -> None:
+ """Process new data manually.
+
+ Implementing class should call this last in update method to render templates.
+ Ex: self._process_manual_data(payload)
+ """
+
+ self.async_write_ha_state()
+ this = None
+ if state := self.hass.states.get(self.entity_id):
+ this = state.as_dict()
+
+ run_variables: dict[str, Any] = {"value": value}
+ # Silently try if variable is a json and store result in `value_json` if it is.
+ with contextlib.suppress(*JSON_DECODE_EXCEPTIONS):
+ run_variables["value_json"] = json_loads(run_variables["value"])
+ variables = {"this": this, **(run_variables or {})}
+
+ self._render_templates(variables)
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index c563ef09a52c..5706e34dc9ca 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -15,7 +15,8 @@ import aiohttp
import requests
from homeassistant import config_entries
-from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
@@ -97,6 +98,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]):
job_name += f" {entry.title} {entry.domain} {entry.entry_id}"
self._job = HassJob(self._handle_refresh_interval, job_name)
self._unsub_refresh: CALLBACK_TYPE | None = None
+ self._unsub_shutdown: CALLBACK_TYPE | None = None
self._request_refresh_task: asyncio.TimerHandle | None = None
self.last_update_success = True
self.last_exception: Exception | None = None
@@ -117,6 +119,22 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]):
if self.config_entry:
self.config_entry.async_on_unload(self.async_shutdown)
+ async def async_register_shutdown(self) -> None:
+ """Register shutdown on HomeAssistant stop.
+
+ Should only be used by coordinators that are not linked to a config entry.
+ """
+ if self.config_entry:
+ raise RuntimeError("This should only be used outside of config entries.")
+
+ async def _on_hass_stop(_: Event) -> None:
+ """Shutdown coordinator on HomeAssistant stop."""
+ await self.async_shutdown()
+
+ self._unsub_shutdown = self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, _on_hass_stop
+ )
+
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
@@ -149,6 +167,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]):
"""Cancel any scheduled call, and ignore new runs."""
self._shutdown_requested = True
self._async_unsub_refresh()
+ self._async_unsub_shutdown()
await self._debounced_refresh.async_shutdown()
@callback
@@ -169,6 +188,12 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]):
self._unsub_refresh()
self._unsub_refresh = None
+ def _async_unsub_shutdown(self) -> None:
+ """Cancel any scheduled call."""
+ if self._unsub_shutdown:
+ self._unsub_shutdown()
+ self._unsub_shutdown = None
+
@callback
def _schedule_refresh(self) -> None:
"""Schedule a refresh."""
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 963ddcf48fc4..6a8131d2454e 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -889,43 +889,48 @@ async def async_get_integrations(
cache = hass.data[DATA_INTEGRATIONS] = {}
results: dict[str, Integration | Exception] = {}
- needed: dict[str, asyncio.Event] = {}
- in_progress: dict[str, asyncio.Event] = {}
+ needed: dict[str, asyncio.Future[None]] = {}
+ in_progress: dict[str, asyncio.Future[None]] = {}
+ if TYPE_CHECKING:
+ cache = cast(dict[str, Integration | asyncio.Future[None]], cache)
for domain in domains:
- int_or_evt: Integration | asyncio.Event | None = cache.get(domain, _UNDEF)
- if isinstance(int_or_evt, asyncio.Event):
- in_progress[domain] = int_or_evt
- elif int_or_evt is not _UNDEF:
- results[domain] = cast(Integration, int_or_evt)
+ int_or_fut = cache.get(domain, _UNDEF)
+ # Integration is never subclassed, so we can check for type
+ if type(int_or_fut) is Integration: # pylint: disable=unidiomatic-typecheck
+ results[domain] = int_or_fut
+ elif int_or_fut is not _UNDEF:
+ in_progress[domain] = cast(asyncio.Future[None], int_or_fut)
elif "." in domain:
results[domain] = ValueError(f"Invalid domain {domain}")
else:
- needed[domain] = cache[domain] = asyncio.Event()
+ needed[domain] = cache[domain] = hass.loop.create_future()
if in_progress:
- await asyncio.gather(*[event.wait() for event in in_progress.values()])
+ await asyncio.gather(*in_progress.values())
for domain in in_progress:
# When we have waited and it's _UNDEF, it doesn't exist
# We don't cache that it doesn't exist, or else people can't fix it
# and then restart, because their config will never be valid.
- if (int_or_evt := cache.get(domain, _UNDEF)) is _UNDEF:
+ if (int_or_fut := cache.get(domain, _UNDEF)) is _UNDEF:
results[domain] = IntegrationNotFound(domain)
else:
- results[domain] = cast(Integration, int_or_evt)
+ results[domain] = cast(Integration, int_or_fut)
+
+ if not needed:
+ return results
# First we look for custom components
- if needed:
- # Instead of using resolve_from_root we use the cache of custom
- # components to find the integration.
- custom = await async_get_custom_components(hass)
- for domain, event in needed.items():
- if integration := custom.get(domain):
- results[domain] = cache[domain] = integration
- event.set()
+ # Instead of using resolve_from_root we use the cache of custom
+ # components to find the integration.
+ custom = await async_get_custom_components(hass)
+ for domain, future in needed.items():
+ if integration := custom.get(domain):
+ results[domain] = cache[domain] = integration
+ future.set_result(None)
- for domain in results:
- if domain in needed:
- del needed[domain]
+ for domain in results:
+ if domain in needed:
+ del needed[domain]
# Now the rest use resolve_from_root
if needed:
@@ -934,7 +939,7 @@ async def async_get_integrations(
integrations = await hass.async_add_executor_job(
_resolve_integrations_from_root, hass, components, list(needed)
)
- for domain, event in needed.items():
+ for domain, future in needed.items():
int_or_exc = integrations.get(domain)
if not int_or_exc:
cache.pop(domain)
@@ -946,7 +951,7 @@ async def async_get_integrations(
results[domain] = exc
else:
results[domain] = cache[domain] = int_or_exc
- event.set()
+ future.set_result(None)
return results
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 2f23efddbb7b..22d47a0e430c 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -1,4 +1,4 @@
-PyJWT==2.6.0
+PyJWT==2.7.0
PyNaCl==1.5.0
PyTurboJPEG==1.6.7
aiodiscover==1.4.16
@@ -19,21 +19,21 @@ bluetooth-data-tools==0.4.0
certifi>=2021.5.30
ciso8601==2.3.0
cryptography==40.0.2
-dbus-fast==1.85.0
+dbus-fast==1.86.0
fnv-hash-fast==0.3.1
-ha-av==10.0.0
-hass-nabucasa==0.66.2
+ha-av==10.1.0
+hass-nabucasa==0.67.1
hassil==1.0.6
home-assistant-bluetooth==1.10.0
-home-assistant-frontend==20230503.3
-home-assistant-intents==2023.4.26
+home-assistant-frontend==20230607.0
+home-assistant-intents==2023.6.5
httpx==0.24.1
-ifaddr==0.1.7
+ifaddr==0.2.0
janus==1.0.0
jinja2==3.1.2
lru-dict==1.1.8
mutagen==1.46.0
-orjson==3.8.10
+orjson==3.8.12
paho-mqtt==1.6.1
pillow==9.5.0
pip>=21.0,<23.2
@@ -43,16 +43,16 @@ pyserial==3.5
python-slugify==4.0.1
pyudev==0.23.2
pyyaml==6.0
-requests==2.28.2
+requests==2.31.0
scapy==2.5.0
-sqlalchemy==2.0.12
+sqlalchemy==2.0.15
typing-extensions>=4.5.0,<5.0
ulid-transform==0.7.2
voluptuous-serialize==2.6.0
voluptuous==0.13.1
webrtcvad==2.0.10
yarl==1.9.2
-zeroconf==0.58.2
+zeroconf==0.64.0
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -152,13 +152,9 @@ matplotlib==3.6.1
# cryptography 40.0.1 is installed with botocore
pyOpenSSL>=23.1.0
-# uamqp newer versions we currently can't build for armv7/armhf
-# Limit this to Python 3.10, to not block Python 3.11 dev for now
-uamqp==1.6.0;python_version<'3.11'
-
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==4.22.3
+protobuf==4.23.1
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
@@ -179,3 +175,11 @@ pysnmplib==5.0.21
# pysnmp is no longer maintained and does not work with newer
# python
pysnmp==1000000000.0.0
+
+# pyminiaudio 1.58 is missing files in the package
+# https://github.com/irmen/pyminiaudio/issues/67
+miniaudio==1.57
+
+# The get-mac package has been replaced with getmac. Installing get-mac alongside getmac
+# breaks getmac due to them both sharing the same python package name inside 'getmac'.
+get-mac==1000000000.0.0
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index 36b17690d7e8..d4b9be05ef45 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -18,8 +18,9 @@ from .const import (
PLATFORM_FORMAT,
Platform,
)
-from .core import CALLBACK_TYPE
+from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN
from .exceptions import DependencyError, HomeAssistantError
+from .helpers.issue_registry import IssueSeverity, async_create_issue
from .helpers.typing import ConfigType
from .util import dt as dt_util, ensure_unique_string
@@ -222,6 +223,31 @@ async def _async_setup_component(
log_error("Invalid config.")
return False
+ # Detect attempt to setup integration which can be setup only from config entry
+ if (
+ domain in processed_config
+ and not hasattr(component, "async_setup")
+ and not hasattr(component, "setup")
+ and not hasattr(component, "CONFIG_SCHEMA")
+ ):
+ _LOGGER.error(
+ (
+ "The %s integration does not support YAML setup, please remove it from "
+ "your configuration"
+ ),
+ domain,
+ )
+ async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"integration_key_no_support_{domain}",
+ is_fixable=False,
+ severity=IssueSeverity.ERROR,
+ issue_domain=domain,
+ translation_key="integration_key_no_support",
+ translation_placeholders={"domain": domain},
+ )
+
start = timer()
_LOGGER.info("Setting up %s", domain)
with async_start_setup(hass, [domain]):
diff --git a/homeassistant/strings.json b/homeassistant/strings.json
index c4cf0593aaeb..4da9c25ca107 100644
--- a/homeassistant/strings.json
+++ b/homeassistant/strings.json
@@ -87,6 +87,10 @@
"unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
"cloud_not_connected": "Not connected to Home Assistant Cloud."
}
+ },
+ "issues": {
+ "platform_integration_no_support_title": "Platform support not supported",
+ "platform_integration_no_support_description": "The {platform} platform for the {domain} integration does not support platform setup.\n\nPlease remove it from your configuration and restart Home Assistant to fix this issue."
}
}
}
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
index 5b119a58c222..4caf074b8794 100644
--- a/homeassistant/util/async_.py
+++ b/homeassistant/util/async_.py
@@ -1,7 +1,7 @@
"""Asyncio utilities."""
from __future__ import annotations
-from asyncio import Semaphore, gather, get_running_loop
+from asyncio import Future, Semaphore, gather, get_running_loop
from asyncio.events import AbstractEventLoop
from collections.abc import Awaitable, Callable
import concurrent.futures
@@ -20,6 +20,13 @@ _R = TypeVar("_R")
_P = ParamSpec("_P")
+def cancelling(task: Future[Any]) -> bool:
+ """Return True if task is done or cancelling."""
+ # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancelling
+ # is new in Python 3.11
+ return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_())
+
+
def run_callback_threadsafe(
loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any
) -> concurrent.futures.Future[_T]:
diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py
index 615024e059cc..4ec8c74ffa9f 100644
--- a/homeassistant/util/language.py
+++ b/homeassistant/util/language.py
@@ -10,6 +10,14 @@ import re
from homeassistant.const import MATCH_ALL
SEPARATOR_RE = re.compile(r"[-_]")
+SAME_LANGUAGES = (
+ # no = spoken Norwegian
+ # nb = written Norwegian (Bokmål)
+ ("nb", "no"),
+ # he = Hebrew new code
+ # iw = Hebrew old code
+ ("he", "iw"),
+)
def preferred_regions(
@@ -60,9 +68,7 @@ def is_language_match(lang_1: str, lang_2: str) -> bool:
# Exact match
return True
- if {lang_1, lang_2} == {"no", "nb"}:
- # no = spoken Norwegian
- # nb = written Norwegian (Bokmål)
+ if tuple(sorted([lang_1, lang_2])) in SAME_LANGUAGES:
return True
return False
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 0fde15acd71a..5ce31b072cf8 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -1,7 +1,12 @@
"""Typing Helpers for Home Assistant."""
from __future__ import annotations
+from collections.abc import Callable
+from functools import lru_cache
+
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UNIT_NOT_RECOGNIZED_TEMPLATE,
UnitOfDataRate,
@@ -65,30 +70,49 @@ class BaseUnitConverter:
@classmethod
def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
"""Convert one unit of measurement to another."""
- if from_unit == to_unit:
- return value
-
- try:
- from_ratio = cls._UNIT_CONVERSION[from_unit]
- except KeyError as err:
- raise HomeAssistantError(
- UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, cls.UNIT_CLASS)
- ) from err
-
- try:
- to_ratio = cls._UNIT_CONVERSION[to_unit]
- except KeyError as err:
- raise HomeAssistantError(
- UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
- ) from err
-
- new_value = value / from_ratio
- return new_value * to_ratio
+ return cls.converter_factory(from_unit, to_unit)(value)
@classmethod
+ @lru_cache
+ def converter_factory(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> Callable[[float], float]:
+ """Return a function to convert one unit of measurement to another."""
+ if from_unit == to_unit:
+ return lambda value: value
+ from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
+ return lambda val: (val / from_ratio) * to_ratio
+
+ @classmethod
+ def _get_from_to_ratio(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> tuple[float, float]:
+ """Get unit ratio between units of measurement."""
+ unit_conversion = cls._UNIT_CONVERSION
+ try:
+ return unit_conversion[from_unit], unit_conversion[to_unit]
+ except KeyError as err:
+ raise HomeAssistantError(
+ UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS)
+ ) from err
+
+ @classmethod
+ @lru_cache
+ def converter_factory_allow_none(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> Callable[[float | None], float | None]:
+ """Return a function to convert one unit of measurement to another which allows None."""
+ if from_unit == to_unit:
+ return lambda value: value
+ from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
+ return lambda val: None if val is None else (val / from_ratio) * to_ratio
+
+ @classmethod
+ @lru_cache
def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float:
"""Get unit ratio between units of measurement."""
- return cls._UNIT_CONVERSION[from_unit] / cls._UNIT_CONVERSION[to_unit]
+ from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
+ return from_ratio / to_ratio
class DataRateConverter(BaseUnitConverter):
@@ -337,7 +361,37 @@ class TemperatureConverter(BaseUnitConverter):
}
@classmethod
- def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
+ @lru_cache(maxsize=8)
+ def converter_factory(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> Callable[[float], float]:
+ """Return a function to convert a temperature from one unit to another."""
+ if from_unit == to_unit:
+ # Return a function that does nothing. This is not
+ # in _converter_factory because we do not want to wrap
+ # it with the None check in converter_factory_allow_none.
+ return lambda value: value
+
+ return cls._converter_factory(from_unit, to_unit)
+
+ @classmethod
+ @lru_cache(maxsize=8)
+ def converter_factory_allow_none(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> Callable[[float | None], float | None]:
+ """Return a function to convert a temperature from one unit to another which allows None."""
+ if from_unit == to_unit:
+ # Return a function that does nothing. This is not
+ # in _converter_factory because we do not want to wrap
+ # it with the None check in this case.
+ return lambda value: value
+ convert = cls._converter_factory(from_unit, to_unit)
+ return lambda value: None if value is None else convert(value)
+
+ @classmethod
+ def _converter_factory(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> Callable[[float], float]:
"""Convert a temperature from one unit to another.
eg. 10°C will return 50°F
@@ -347,32 +401,29 @@ class TemperatureConverter(BaseUnitConverter):
"""
# We cannot use the implementation from BaseUnitConverter here because the
# temperature units do not use the same floor: 0°C, 0°F and 0K do not align
- if from_unit == to_unit:
- return value
-
if from_unit == UnitOfTemperature.CELSIUS:
if to_unit == UnitOfTemperature.FAHRENHEIT:
- return cls._celsius_to_fahrenheit(value)
+ return cls._celsius_to_fahrenheit
if to_unit == UnitOfTemperature.KELVIN:
- return cls._celsius_to_kelvin(value)
+ return cls._celsius_to_kelvin
raise HomeAssistantError(
UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
)
if from_unit == UnitOfTemperature.FAHRENHEIT:
if to_unit == UnitOfTemperature.CELSIUS:
- return cls._fahrenheit_to_celsius(value)
+ return cls._fahrenheit_to_celsius
if to_unit == UnitOfTemperature.KELVIN:
- return cls._celsius_to_kelvin(cls._fahrenheit_to_celsius(value))
+ return cls._fahrenheit_to_kelvin
raise HomeAssistantError(
UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
)
if from_unit == UnitOfTemperature.KELVIN:
if to_unit == UnitOfTemperature.CELSIUS:
- return cls._kelvin_to_celsius(value)
+ return cls._kelvin_to_celsius
if to_unit == UnitOfTemperature.FAHRENHEIT:
- return cls._celsius_to_fahrenheit(cls._kelvin_to_celsius(value))
+ return cls._kelvin_to_fahrenheit
raise HomeAssistantError(
UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
)
@@ -391,7 +442,17 @@ class TemperatureConverter(BaseUnitConverter):
"""
# We use BaseUnitConverter implementation here because we are only interested
# in the ratio between the units.
- return super().convert(interval, from_unit, to_unit)
+ return super().converter_factory(from_unit, to_unit)(interval)
+
+ @classmethod
+ def _kelvin_to_fahrenheit(cls, kelvin: float) -> float:
+ """Convert a temperature in Kelvin to Fahrenheit."""
+ return (kelvin - 273.15) * 1.8 + 32.0
+
+ @classmethod
+ def _fahrenheit_to_kelvin(cls, fahrenheit: float) -> float:
+ """Convert a temperature in Fahrenheit to Kelvin."""
+ return 273.15 + ((fahrenheit - 32.0) / 1.8)
@classmethod
def _fahrenheit_to_celsius(cls, fahrenheit: float) -> float:
@@ -421,6 +482,8 @@ class UnitlessRatioConverter(BaseUnitConverter):
NORMALIZED_UNIT = None
_UNIT_CONVERSION: dict[str | None, float] = {
None: 1,
+ CONCENTRATION_PARTS_PER_BILLION: 1000000000,
+ CONCENTRATION_PARTS_PER_MILLION: 1000000,
PERCENTAGE: 100,
}
VALID_UNITS = {
diff --git a/machine/generic-x86-64 b/machine/generic-x86-64
index b7fbac2e8edc..bbd72e93d513 100644
--- a/machine/generic-x86-64
+++ b/machine/generic-x86-64
@@ -2,5 +2,4 @@ ARG BUILD_VERSION
FROM homeassistant/amd64-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
- libva-intel-driver \
- usbutils
+ libva-intel-driver
diff --git a/machine/intel-nuc b/machine/intel-nuc
index 5e1b7f957d1c..9b8a2b3eef97 100644
--- a/machine/intel-nuc
+++ b/machine/intel-nuc
@@ -5,5 +5,4 @@ FROM homeassistant/amd64-homeassistant:$BUILD_VERSION
# changes in generic-x86-64 as well.
RUN apk --no-cache add \
- libva-intel-driver \
- usbutils
+ libva-intel-driver
diff --git a/machine/khadas-vim3 b/machine/khadas-vim3
index 2109262b3424..af934f31f0e3 100644
--- a/machine/khadas-vim3
+++ b/machine/khadas-vim3
@@ -1,9 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
diff --git a/machine/odroid-c2 b/machine/odroid-c2
index be07d6c8abae..af934f31f0e3 100644
--- a/machine/odroid-c2
+++ b/machine/odroid-c2
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/odroid-c4 b/machine/odroid-c4
index be07d6c8abae..af934f31f0e3 100644
--- a/machine/odroid-c4
+++ b/machine/odroid-c4
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/odroid-m1 b/machine/odroid-m1
index be07d6c8abae..af934f31f0e3 100644
--- a/machine/odroid-m1
+++ b/machine/odroid-m1
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/odroid-n2 b/machine/odroid-n2
index be07d6c8abae..af934f31f0e3 100644
--- a/machine/odroid-n2
+++ b/machine/odroid-n2
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/odroid-xu b/machine/odroid-xu
index 3aa428d3f52b..57f6d0cddb8e 100644
--- a/machine/odroid-xu
+++ b/machine/odroid-xu
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/armv7-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/qemuarm b/machine/qemuarm
index e00c945e945b..54fc002097a4 100644
--- a/machine/qemuarm
+++ b/machine/qemuarm
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/armhf-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/qemuarm-64 b/machine/qemuarm-64
index be07d6c8abae..af934f31f0e3 100644
--- a/machine/qemuarm-64
+++ b/machine/qemuarm-64
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/qemux86 b/machine/qemux86
index 1b5350df4c8c..23264cb6b18e 100644
--- a/machine/qemux86
+++ b/machine/qemux86
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/i386-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/qemux86-64 b/machine/qemux86-64
index 541e994b9678..3e7a43d13354 100644
--- a/machine/qemux86-64
+++ b/machine/qemux86-64
@@ -1,5 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/amd64-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add \
- usbutils
diff --git a/machine/raspberrypi b/machine/raspberrypi
index 8ea2d08bba70..876fcfce21e3 100644
--- a/machine/raspberrypi
+++ b/machine/raspberrypi
@@ -3,8 +3,7 @@ FROM homeassistant/armhf-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils
+ raspberrypi-libs
##
# Set symlinks for raspberry pi camera binaries.
diff --git a/machine/raspberrypi2 b/machine/raspberrypi2
index 45f7a9c80d1f..5d109e240b7b 100644
--- a/machine/raspberrypi2
+++ b/machine/raspberrypi2
@@ -3,8 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/machine/raspberrypi3 b/machine/raspberrypi3
index 251825867079..5d109e240b7b 100644
--- a/machine/raspberrypi3
+++ b/machine/raspberrypi3
@@ -3,12 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64
index 48838cc13e1f..9205b90389a3 100644
--- a/machine/raspberrypi3-64
+++ b/machine/raspberrypi3-64
@@ -3,12 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/machine/raspberrypi4 b/machine/raspberrypi4
index 251825867079..5d109e240b7b 100644
--- a/machine/raspberrypi4
+++ b/machine/raspberrypi4
@@ -3,12 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64
index 48838cc13e1f..9205b90389a3 100644
--- a/machine/raspberrypi4-64
+++ b/machine/raspberrypi4-64
@@ -3,12 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/machine/tinker b/machine/tinker
index 2fb01f2b5453..57f6d0cddb8e 100644
--- a/machine/tinker
+++ b/machine/tinker
@@ -1,8 +1,2 @@
ARG BUILD_VERSION
FROM homeassistant/armv7-homeassistant:$BUILD_VERSION
-
-RUN apk --no-cache add usbutils \
- && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \
- pybluez \
- -c /usr/src/homeassistant/requirements_all.txt \
- --use-deprecated=legacy-resolver
diff --git a/machine/yellow b/machine/yellow
index d8e7421f9b1f..9205b90389a3 100644
--- a/machine/yellow
+++ b/machine/yellow
@@ -3,8 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION
RUN apk --no-cache add \
raspberrypi \
- raspberrypi-libs \
- usbutils
+ raspberrypi-libs
##
# Set symlinks for raspberry pi binaries.
diff --git a/mypy.ini b/mypy.ini
index 2fe9310907f3..8628353ef6a7 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -251,6 +251,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.airzone_cloud.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.aladdin_connect.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -621,6 +631,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.cloud.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.configurator.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -812,6 +832,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.electrasmart.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.elgato.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1422,6 +1452,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.hydrawise.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.hyperion.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1462,6 +1502,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.imap.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.input_button.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1532,6 +1582,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.jvc_projector.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.kaleidescape.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2062,6 +2122,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.opensky.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.openuv.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2602,6 +2672,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.sql.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.ssdp.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py
index a3cdb1391315..1d7bac65c19b 100644
--- a/pylint/plugins/hass_enforce_type_hints.py
+++ b/pylint/plugins/hass_enforce_type_hints.py
@@ -140,6 +140,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = {
"recorder_mock": "Recorder",
"requests_mock": "requests_mock.Mocker",
"snapshot": "SnapshotAssertion",
+ "stub_blueprint_populate": "None",
"tmp_path": "Path",
"tmpdir": "py.path.local",
}
@@ -2449,7 +2450,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
),
TypeHintMatch(
function_name="get_tts_audio",
- arg_types={1: "str", 2: "str", 3: "dict[str, Any] | None"},
+ arg_types={1: "str", 2: "str", 3: "dict[str, Any]"},
return_type="TtsAudioType",
has_async_counterpart=True,
),
diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py
new file mode 100644
index 000000000000..716479202c74
--- /dev/null
+++ b/pylint/plugins/hass_inheritance.py
@@ -0,0 +1,76 @@
+"""Plugin to enforce type hints on specific functions."""
+from __future__ import annotations
+
+import re
+
+from astroid import nodes
+from pylint.checkers import BaseChecker
+from pylint.lint import PyLinter
+
+_MODULE_REGEX: re.Pattern[str] = re.compile(r"^homeassistant\.components\.\w+(\.\w+)?$")
+
+
+def _get_module_platform(module_name: str) -> str | None:
+ """Return the platform for the module name."""
+ if not (module_match := _MODULE_REGEX.match(module_name)):
+ # Ensure `homeassistant.components.`
+ # Or `homeassistant.components..`
+ return None
+
+ platform = module_match.groups()[0]
+ return platform.lstrip(".") if platform else "__init__"
+
+
+class HassInheritanceChecker(BaseChecker): # type: ignore[misc]
+ """Checker for invalid inheritance."""
+
+ name = "hass_inheritance"
+ priority = -1
+ msgs = {
+ "W7411": (
+ "Invalid inheritance: %s",
+ "hass-invalid-inheritance",
+ "Used when a class has inheritance has issues",
+ ),
+ }
+ options = ()
+
+ _module_name: str
+ _module_platform: str | None
+
+ def visit_module(self, node: nodes.Module) -> None:
+ """Populate matchers for a Module node."""
+ self._module_name = node.name
+ self._module_platform = _get_module_platform(node.name)
+
+ def visit_classdef(self, node: nodes.ClassDef) -> None:
+ """Apply relevant type hint checks on a ClassDef node."""
+ if self._module_platform not in {"number", "sensor"}:
+ return
+
+ ancestors = [a.name for a in node.ancestors()]
+ if (
+ "RestoreEntity" in ancestors
+ and "SensorEntity" in ancestors
+ and "RestoreSensor" not in ancestors
+ ):
+ self.add_message(
+ "hass-invalid-inheritance",
+ node=node,
+ args="SensorEntity and RestoreEntity should not be combined, please use RestoreSensor",
+ )
+ elif (
+ "RestoreEntity" in ancestors
+ and "NumberEntity" in ancestors
+ and "RestoreNumber" not in ancestors
+ ):
+ self.add_message(
+ "hass-invalid-inheritance",
+ node=node,
+ args="NumberEntity and RestoreEntity should not be combined, please use RestoreNumber",
+ )
+
+
+def register(linter: PyLinter) -> None:
+ """Register the checker."""
+ linter.register_checker(HassInheritanceChecker(linter))
diff --git a/pyproject.toml b/pyproject.toml
index 023f67ac610a..2ece07d96e63 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2023.5.4"
+version = "2023.6.0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -36,19 +36,19 @@ dependencies = [
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.24.1",
"home-assistant-bluetooth==1.10.0",
- "ifaddr==0.1.7",
+ "ifaddr==0.2.0",
"jinja2==3.1.2",
"lru-dict==1.1.8",
- "PyJWT==2.6.0",
+ "PyJWT==2.7.0",
# PyJWT has loose dependency. We want the latest one.
"cryptography==40.0.2",
# pyOpenSSL 23.1.0 is required to work with cryptography 39+
"pyOpenSSL==23.1.0",
- "orjson==3.8.10",
+ "orjson==3.8.12",
"pip>=21.0,<23.2",
"python-slugify==4.0.1",
"pyyaml==6.0",
- "requests==2.28.2",
+ "requests==2.31.0",
"typing-extensions>=4.5.0,<5.0",
"ulid-transform==0.7.2",
"voluptuous==0.13.1",
@@ -113,6 +113,7 @@ load-plugins = [
"pylint.extensions.code_style",
"pylint.extensions.typing",
"hass_enforce_type_hints",
+ "hass_inheritance",
"hass_imports",
"hass_logger",
"pylint_per_file_ignores",
@@ -283,6 +284,7 @@ voluptuous = "vol"
"homeassistant.helpers.device_registry" = "dr"
"homeassistant.helpers.entity_registry" = "er"
"homeassistant.helpers.issue_registry" = "ir"
+"homeassistant.util.dt" = "dt_util"
[tool.ruff.flake8-pytest-style]
fixture-parentheses = false
diff --git a/requirements.txt b/requirements.txt
index b4bee14dec4e..818eeec8515c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,17 +12,17 @@ certifi>=2021.5.30
ciso8601==2.3.0
httpx==0.24.1
home-assistant-bluetooth==1.10.0
-ifaddr==0.1.7
+ifaddr==0.2.0
jinja2==3.1.2
lru-dict==1.1.8
-PyJWT==2.6.0
+PyJWT==2.7.0
cryptography==40.0.2
pyOpenSSL==23.1.0
-orjson==3.8.10
+orjson==3.8.12
pip>=21.0,<23.2
python-slugify==4.0.1
pyyaml==6.0
-requests==2.28.2
+requests==2.31.0
typing-extensions>=4.5.0,<5.0
ulid-transform==0.7.2
voluptuous==0.13.1
diff --git a/requirements_all.txt b/requirements_all.txt
index 87a98fdfe99e..8bb4580aa619 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -71,7 +71,7 @@ WSDiscovery==2.0.0
WazeRouteCalculator==0.14
# homeassistant.components.accuweather
-accuweather==0.5.2
+accuweather==1.0.0
# homeassistant.components.adax
adax==0.2.0
@@ -115,8 +115,11 @@ aio_georss_gdacs==0.8
# homeassistant.components.airq
aioairq==0.2.4
+# homeassistant.components.airzone_cloud
+aioairzone-cloud==0.1.7
+
# homeassistant.components.airzone
-aioairzone==0.5.5
+aioairzone==0.6.3
# homeassistant.components.ambient_station
aioambient==2023.04.0
@@ -131,7 +134,7 @@ aioasuswrt==1.4.0
aioazuredevops==1.3.5
# homeassistant.components.baf
-aiobafi6==0.8.0
+aiobafi6==0.8.2
# homeassistant.components.aws
aiobotocore==2.1.0
@@ -150,13 +153,13 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2023.01.0
+aioecowitt==2023.5.0
# homeassistant.components.emonitor
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==13.7.4
+aioesphomeapi==13.9.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -174,7 +177,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
-aiohomekit==2.6.3
+aiohomekit==2.6.4
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -291,7 +294,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.5
# homeassistant.components.unifi
-aiounifi==47
+aiounifi==48
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -333,13 +336,13 @@ amcrest==1.9.7
androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote
-androidtvremote2==0.0.7
+androidtvremote2==0.0.9
# homeassistant.components.anel_pwrctrl
anel_pwrctrl-homeassistant==0.0.1.dev2
# homeassistant.components.anova
-anova-wifi==0.8.0
+anova-wifi==0.10.0
# homeassistant.components.anthemav
anthemav==1.4.1
@@ -348,7 +351,7 @@ anthemav==1.4.1
apcaccess==0.0.13
# homeassistant.components.apprise
-apprise==1.3.0
+apprise==1.4.0
# homeassistant.components.aprs
aprslib==0.7.0
@@ -401,13 +404,13 @@ aurorapy==0.2.7
# avion==0.10
# homeassistant.components.axis
-axis==47
+axis==48
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
# homeassistant.components.azure_service_bus
-# azure-servicebus==7.8.0
+azure-servicebus==7.10.0
# homeassistant.components.baidu
baidu-aip==1.6.6
@@ -431,7 +434,7 @@ beautifulsoup4==4.11.1
bellows==0.35.5
# homeassistant.components.bmw_connected_drive
-bimmer_connected==0.13.5
+bimmer_connected==0.13.6
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
@@ -446,7 +449,7 @@ bleak==0.20.2
blebox_uniapi==2.1.4
# homeassistant.components.blink
-blinkpy==0.19.2
+blinkpy==0.21.0
# homeassistant.components.blinksticklight
blinkstick==1.2.0
@@ -499,7 +502,7 @@ brunt==1.2.0
bt_proximity==0.2.1
# homeassistant.components.bthome
-bthome-ble==2.9.0
+bthome-ble==2.11.3
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -567,7 +570,7 @@ datadog==0.15.0
datapoint==0.9.8
# homeassistant.components.bluetooth
-dbus-fast==1.85.0
+dbus-fast==1.86.0
# homeassistant.components.debugpy
debugpy==1.6.7
@@ -596,7 +599,7 @@ denonavr==0.11.2
devolo-home-control-api==0.18.2
# homeassistant.components.devolo_home_network
-devolo-plc-api==1.2.0
+devolo-plc-api==1.3.1
# homeassistant.components.directv
directv==0.4.0
@@ -625,6 +628,9 @@ dweepy==0.3.0
# homeassistant.components.dynalite
dynalite_devices==0.1.47
+# homeassistant.components.dynalite
+dynalite_panel==0.0.4
+
# homeassistant.components.rainforest_eagle
eagle100==0.1.1
@@ -805,6 +811,7 @@ goalzero==0.2.1
goodwe==0.2.31
# homeassistant.components.google_mail
+# homeassistant.components.youtube
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub
@@ -813,6 +820,9 @@ google-cloud-pubsub==2.13.11
# homeassistant.components.google_cloud
google-cloud-texttospeech==2.12.3
+# homeassistant.components.google_generative_ai_conversation
+google-generativeai==0.1.0rc2
+
# homeassistant.components.nest
google-nest-sdm==2.2.4
@@ -853,18 +863,21 @@ gspread==5.5.0
gstreamer-player==1.1.2
# homeassistant.components.profiler
-guppy3==3.1.2;python_version<'3.11'
+guppy3==3.1.3
# homeassistant.components.iaqualink
h2==4.1.0
# homeassistant.components.generic
# homeassistant.components.stream
-ha-av==10.0.0
+ha-av==10.1.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.1.0
+# homeassistant.components.iotawatt
+ha-iotawattpy==0.1.1
+
# homeassistant.components.philips_js
ha-philipsjs==3.0.0
@@ -872,7 +885,7 @@ ha-philipsjs==3.0.0
habitipy==0.2.0
# homeassistant.components.cloud
-hass-nabucasa==0.66.2
+hass-nabucasa==0.67.1
# homeassistant.components.splunk
hass_splunk==0.1.1
@@ -911,10 +924,10 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
-home-assistant-frontend==20230503.3
+home-assistant-frontend==20230607.0
# homeassistant.components.conversation
-home-assistant-intents==2023.4.26
+home-assistant-intents==2023.6.5
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -949,9 +962,6 @@ iaqualink==0.5.0
# homeassistant.components.ibeacon
ibeacon_ble==1.0.1
-# homeassistant.components.watson_tts
-ibm-watson==5.2.2
-
# homeassistant.components.watson_iot
ibmiotf==0.3.4
@@ -962,7 +972,7 @@ ical==4.5.1
icmplib==3.0
# homeassistant.components.network
-ifaddr==0.1.7
+ifaddr==0.2.0
# homeassistant.components.iglo
iglo==1.2.7
@@ -988,9 +998,6 @@ insteon-frontend-home-assistant==0.3.5
# homeassistant.components.intellifire
intellifire4py==2.2.2
-# homeassistant.components.iotawatt
-iotawattpy==0.1.0
-
# homeassistant.components.iperf3
iperf3==0.1.11
@@ -1024,6 +1031,9 @@ kegtron-ble==0.4.0
# homeassistant.components.kiwi
kiwiki-client==0.1.1
+# homeassistant.components.knx
+knx_frontend==2023.5.31.141540
+
# homeassistant.components.konnected
konnected==1.2.0
@@ -1031,7 +1041,7 @@ konnected==1.2.0
krakenex==2.1.0
# homeassistant.components.lacrosse_view
-lacrosse-view==0.0.9
+lacrosse-view==1.0.1
# homeassistant.components.eufy
lakeside==0.13
@@ -1064,7 +1074,7 @@ life360==5.5.0
lightify==1.0.7.3
# homeassistant.components.lightwave
-lightwave==0.20
+lightwave==0.24
# homeassistant.components.limitlessled
limitlessled==1.1.3
@@ -1112,7 +1122,7 @@ mcstatus==6.0.0
meater-python==0.0.8
# homeassistant.components.melnor
-melnor-bluetooth==0.0.20
+melnor-bluetooth==0.0.24
# homeassistant.components.message_bird
messagebird==1.2.0
@@ -1148,7 +1158,7 @@ moehlenhoff-alpha2==1.3.0
mopeka_iot_ble==0.4.1
# homeassistant.components.motion_blinds
-motionblinds==0.6.17
+motionblinds==0.6.18
# homeassistant.components.motioneye
motioneye-client==0.3.14
@@ -1162,9 +1172,6 @@ mutagen==1.46.0
# homeassistant.components.mutesync
mutesync==0.0.1
-# homeassistant.components.mycroft
-mycroftapi==2.0
-
# homeassistant.components.nad
nad_receiver==0.3.0
@@ -1175,7 +1182,7 @@ ndms2_client==0.1.2
nessclient==0.10.0
# homeassistant.components.netdata
-netdata==1.0.1
+netdata==1.1.0
# homeassistant.components.discovery
netdisco==3.0.0
@@ -1202,7 +1209,7 @@ nextcord==2.0.0a8
nextdns==1.4.0
# homeassistant.components.nibe_heatpump
-nibe==2.1.4
+nibe==2.2.0
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
@@ -1264,7 +1271,7 @@ ondilo==0.2.0
onkyo-eiscp==1.2.7
# homeassistant.components.onvif
-onvif-zeep-async==3.1.7
+onvif-zeep-async==3.1.9
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -1319,7 +1326,7 @@ p1monitor==2.1.1
paho-mqtt==1.6.1
# homeassistant.components.panasonic_bluray
-panacotta==0.1
+panacotta==0.2
# homeassistant.components.panasonic_viera
panasonic_viera==0.3.6
@@ -1466,7 +1473,7 @@ pyMetEireann==2021.8.0
# homeassistant.components.met
# homeassistant.components.norway_air
-pyMetno==0.9.0
+pyMetno==0.10.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.30.1
@@ -1475,7 +1482,7 @@ pyRFXtrx==0.30.1
pySwitchmate==0.5.1
# homeassistant.components.tibber
-pyTibber==0.27.1
+pyTibber==0.27.2
# homeassistant.components.dlink
pyW215==0.7.0
@@ -1512,7 +1519,7 @@ pyatmo==7.5.0
pyatome==0.1.1
# homeassistant.components.apple_tv
-pyatv==0.11.0
+pyatv==0.12.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1569,13 +1576,13 @@ pycsspeechtts==1.0.8
# pycups==1.9.73
# homeassistant.components.daikin
-pydaikin==2.9.0
+pydaikin==2.9.1
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
# homeassistant.components.deconz
-pydeconz==111
+pydeconz==112
# homeassistant.components.delijn
pydelijn==1.0.0
@@ -1604,6 +1611,9 @@ pyefergy==22.1.1
# homeassistant.components.eight_sleep
pyeight==0.3.2
+# homeassistant.components.electrasmart
+pyelectra==1.2.0
+
# homeassistant.components.emby
pyemby==1.8
@@ -1620,10 +1630,10 @@ pyeverlights==0.1.0
pyevilgenius==2.0.0
# homeassistant.components.ezviz
-pyezviz==0.2.0.9
+pyezviz==0.2.0.12
# homeassistant.components.fibaro
-pyfibaro==0.7.0
+pyfibaro==0.7.1
# homeassistant.components.fido
pyfido==2.1.2
@@ -1662,7 +1672,7 @@ pygatt[GATTTOOL]==4.0.5
pygtfs==0.1.7
# homeassistant.components.hvv_departures
-pygti==0.9.3
+pygti==0.9.4
# homeassistant.components.version
pyhaversion==22.8.0
@@ -1698,7 +1708,7 @@ pyintesishome==1.8.0
pyipma==3.0.6
# homeassistant.components.ipp
-pyipp==0.12.1
+pyipp==0.13.0
# homeassistant.components.iqvia
pyiqvia==2022.04.0
@@ -1715,6 +1725,9 @@ pyisy==3.1.14
# homeassistant.components.itach
pyitachip2ir==0.0.7
+# homeassistant.components.jvc_projector
+pyjvcprojector==1.0.6
+
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1
@@ -1746,7 +1759,7 @@ pylacrosse==0.4
pylast==5.1.0
# homeassistant.components.launch_library
-pylaunches==1.3.0
+pylaunches==1.4.0
# homeassistant.components.lg_netcast
pylgnetcast==0.3.7
@@ -1818,7 +1831,7 @@ pynina==0.3.0
pynobo==1.6.0
# homeassistant.components.nuki
-pynuki==1.6.1
+pynuki==1.6.2
# homeassistant.components.nut
pynut2==2.1.2
@@ -1859,7 +1872,7 @@ pyotgw==2.1.3
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.7.8
+pyoverkiz==1.7.9
# homeassistant.components.openweathermap
pyowm==3.2.0
@@ -1871,7 +1884,7 @@ pyownet==0.10.0.post1
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.7.16
+pypck==0.7.17
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -1937,7 +1950,7 @@ pysaj==0.0.16
pysdcp==1
# homeassistant.components.sensibo
-pysensibo==1.0.25
+pysensibo==1.0.28
# homeassistant.components.serial
# homeassistant.components.zha
@@ -1953,9 +1966,6 @@ pyserial==3.5
# homeassistant.components.sesame
pysesame2==1.0.1
-# homeassistant.components.goalfeed
-pysher==1.0.7
-
# homeassistant.components.sia
pysiaalarm==3.1.1
@@ -1977,11 +1987,8 @@ pysmartapp==0.3.3
# homeassistant.components.smartthings
pysmartthings==0.7.6
-# homeassistant.components.smarty
-pysmarty==0.8
-
# homeassistant.components.edl21
-pysml==0.0.10
+pysml==0.0.12
# homeassistant.components.snmp
pysnmplib==5.0.21
@@ -1996,7 +2003,7 @@ pysoma==0.0.12
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
-pysqueezebox==0.6.1
+pysqueezebox==0.6.3
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2
@@ -2005,7 +2012,7 @@ pystiebeleltron==0.0.1.dev2
pysuez==0.1.19
# homeassistant.components.switchbee
-pyswitchbee==1.7.19
+pyswitchbee==1.8.0
# homeassistant.components.syncthru
pysyncthru==0.7.10
@@ -2089,14 +2096,17 @@ python-miio==0.5.12
python-mpd2==3.0.5
# homeassistant.components.mystrom
-python-mystrom==1.1.2
+python-mystrom==2.2.0
# homeassistant.components.nest
python-nest==4.2.0
+# homeassistant.components.opensky
+python-opensky==0.0.7
+
# homeassistant.components.otbr
# homeassistant.components.thread
-python-otbr-api==1.0.9
+python-otbr-api==2.1.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -2108,7 +2118,7 @@ python-qbittorrent==0.4.2
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==0.8.3
+python-roborock==0.23.4
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -2117,7 +2127,7 @@ python-smarttub==0.0.33
python-songpal==0.15.2
# homeassistant.components.tado
-python-tado==0.12.0
+python-tado==0.15.0
# homeassistant.components.telegram_bot
python-telegram-bot==13.1
@@ -2152,13 +2162,13 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==0.2.3
+pytrafikverket==0.3.3
# homeassistant.components.usb
pyudev==0.23.2
# homeassistant.components.unifiprotect
-pyunifiprotect==4.9.0
+pyunifiprotect==4.10.1
# homeassistant.components.uptimerobot
pyuptimerobot==22.2.0
@@ -2230,7 +2240,7 @@ radiotherm==2.1.0
raincloudy==0.0.7
# homeassistant.components.rapt_ble
-rapt-ble==0.1.0
+rapt-ble==0.1.1
# homeassistant.components.raspyrfm
raspyrfm-client==1.2.8
@@ -2242,7 +2252,7 @@ regenmaschine==2023.05.1
renault-api==0.1.13
# homeassistant.components.reolink
-reolink-aio==0.5.15
+reolink-aio==0.5.16
# homeassistant.components.python_script
restrictedpython==6.0
@@ -2266,7 +2276,7 @@ rjpl==0.3.6
rocketchat-API==0.6.1
# homeassistant.components.roku
-rokuecp==0.17.1
+rokuecp==0.18.0
# homeassistant.components.roomba
roombapy==1.6.8
@@ -2299,7 +2309,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.5.0
+samsungtvws[async,encrypted]==2.6.0
# homeassistant.components.satel_integra
satel_integra==0.3.7
@@ -2333,7 +2343,7 @@ sensorpro-ble==0.5.3
sensorpush-ble==1.5.5
# homeassistant.components.sentry
-sentry-sdk==1.20.0
+sentry-sdk==1.23.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.6
@@ -2354,7 +2364,7 @@ simplehound==0.3
simplepush==2.1.1
# homeassistant.components.simplisafe
-simplisafe-python==2023.04.0
+simplisafe-python==2023.05.0
# homeassistant.components.sisyphus
sisyphus-control==3.1.2
@@ -2372,7 +2382,7 @@ smart-meter-texas==0.4.7
smhi-pkg==1.0.16
# homeassistant.components.snapcast
-snapcast==2.3.2
+snapcast==2.3.3
# homeassistant.components.sonos
soco==0.29.1
@@ -2390,7 +2400,7 @@ solax==0.3.0
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonos
-sonos-websocket==0.1.1
+sonos-websocket==0.1.2
# homeassistant.components.marytts
speak2mary==1.4.0
@@ -2406,7 +2416,7 @@ spotipy==2.23.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==2.0.12
+sqlalchemy==2.0.15
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -2550,7 +2560,7 @@ twentemilieu==1.0.0
twilio==6.32.0
# homeassistant.components.twitch
-twitchAPI==2.5.2
+twitchAPI==3.10.0
# homeassistant.components.ukraine_alarm
uasiren==0.0.1
@@ -2591,7 +2601,7 @@ velbus-aio==2023.2.0
venstarcolortouch==0.19
# homeassistant.components.vilfo
-vilfo-api-client==0.3.2
+vilfo-api-client==0.4.1
# homeassistant.components.voip
voip-utils==0.0.7
@@ -2622,7 +2632,7 @@ wakeonlan==2.1.0
wallbox==0.4.12
# homeassistant.components.waqi
-waqiasync==1.0.0
+waqiasync==1.1.0
# homeassistant.components.folder_watcher
watchdog==2.3.1
@@ -2664,10 +2674,13 @@ wyoming==0.0.1
xbox-webapi==2.0.11
# homeassistant.components.xiaomi_ble
-xiaomi-ble==0.17.0
+xiaomi-ble==0.17.2
# homeassistant.components.knx
-xknx==2.9.0
+xknx==2.10.0
+
+# homeassistant.components.knx
+xknxproject==3.1.0
# homeassistant.components.bluesound
# homeassistant.components.fritz
@@ -2697,7 +2710,7 @@ yeelight==0.7.10
yeelightsunflower==0.0.10
# homeassistant.components.yolink
-yolink-api==0.2.8
+yolink-api==0.2.9
# homeassistant.components.youless
youless-api==1.0.1
@@ -2712,13 +2725,13 @@ zamg==0.2.2
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.58.2
+zeroconf==0.64.0
# homeassistant.components.zeversolar
zeversolar==0.3.1
# homeassistant.components.zha
-zha-quirks==0.0.99
+zha-quirks==0.0.100
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2745,7 +2758,7 @@ zigpy==0.55.0
zm-py==0.5.2
# homeassistant.components.zwave_js
-zwave-js-server-python==0.48.1
+zwave-js-server-python==0.49.0
# homeassistant.components.zwave_me
-zwave_me_ws==0.4.2
+zwave_me_ws==0.4.3
diff --git a/requirements_test.txt b/requirements_test.txt
index 82e861fc9e97..077a74c1b3ac 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,14 +7,14 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
-astroid==2.15.3
-coverage==7.2.3
+astroid==2.15.4
+coverage==7.2.4
freezegun==1.2.2
mock-open==1.4.0
-mypy==1.2.0
+mypy==1.3.0
pre-commit==3.1.0
-pydantic==1.10.7
-pylint==2.17.2
+pydantic==1.10.8
+pylint==2.17.4
pylint-per-file-ignores==1.1.0
pipdeptree==2.7.0
pytest-asyncio==0.20.3
@@ -31,7 +31,7 @@ pytest-xdist==3.2.1
pytest==7.3.1
requests_mock==1.10.0
respx==0.20.1
-syrupy==4.0.0
+syrupy==4.0.2
tomli==2.0.1;python_version<"3.11"
tqdm==4.64.0
types-atomicwrites==1.4.1
@@ -47,5 +47,5 @@ types-python-dateutil==2.8.19.5
types-python-slugify==0.1.2
types-pytz==2022.7.0.0
types-PyYAML==6.0.12.2
-types-requests==2.28.11.6
+types-requests==2.30.0.0
types-toml==0.10.8.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 5ae471a8401e..bc1cae28d098 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -61,7 +61,7 @@ WSDiscovery==2.0.0
WazeRouteCalculator==0.14
# homeassistant.components.accuweather
-accuweather==0.5.2
+accuweather==1.0.0
# homeassistant.components.adax
adax==0.2.0
@@ -105,8 +105,11 @@ aio_georss_gdacs==0.8
# homeassistant.components.airq
aioairq==0.2.4
+# homeassistant.components.airzone_cloud
+aioairzone-cloud==0.1.7
+
# homeassistant.components.airzone
-aioairzone==0.5.5
+aioairzone==0.6.3
# homeassistant.components.ambient_station
aioambient==2023.04.0
@@ -121,7 +124,7 @@ aioasuswrt==1.4.0
aioazuredevops==1.3.5
# homeassistant.components.baf
-aiobafi6==0.8.0
+aiobafi6==0.8.2
# homeassistant.components.aws
aiobotocore==2.1.0
@@ -140,13 +143,13 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2023.01.0
+aioecowitt==2023.5.0
# homeassistant.components.emonitor
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==13.7.4
+aioesphomeapi==13.9.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -161,7 +164,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
-aiohomekit==2.6.3
+aiohomekit==2.6.4
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -272,7 +275,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.5
# homeassistant.components.unifi
-aiounifi==47
+aiounifi==48
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@@ -308,10 +311,10 @@ ambiclimate==0.2.1
androidtv[async]==0.0.70
# homeassistant.components.androidtv_remote
-androidtvremote2==0.0.7
+androidtvremote2==0.0.9
# homeassistant.components.anova
-anova-wifi==0.8.0
+anova-wifi==0.10.0
# homeassistant.components.anthemav
anthemav==1.4.1
@@ -320,7 +323,7 @@ anthemav==1.4.1
apcaccess==0.0.13
# homeassistant.components.apprise
-apprise==1.3.0
+apprise==1.4.0
# homeassistant.components.aprs
aprslib==0.7.0
@@ -349,7 +352,7 @@ auroranoaa==0.0.3
aurorapy==0.2.7
# homeassistant.components.axis
-axis==47
+axis==48
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -364,7 +367,7 @@ beautifulsoup4==4.11.1
bellows==0.35.5
# homeassistant.components.bmw_connected_drive
-bimmer_connected==0.13.5
+bimmer_connected==0.13.6
# homeassistant.components.bluetooth
bleak-retry-connector==3.0.2
@@ -376,7 +379,7 @@ bleak==0.20.2
blebox_uniapi==2.1.4
# homeassistant.components.blink
-blinkpy==0.19.2
+blinkpy==0.21.0
# homeassistant.components.bluemaestro
bluemaestro-ble==0.2.3
@@ -412,7 +415,7 @@ brottsplatskartan==0.0.1
brunt==1.2.0
# homeassistant.components.bthome
-bthome-ble==2.9.0
+bthome-ble==2.11.3
# homeassistant.components.buienradar
buienradar==1.0.5
@@ -456,7 +459,7 @@ datadog==0.15.0
datapoint==0.9.8
# homeassistant.components.bluetooth
-dbus-fast==1.85.0
+dbus-fast==1.86.0
# homeassistant.components.debugpy
debugpy==1.6.7
@@ -479,7 +482,7 @@ denonavr==0.11.2
devolo-home-control-api==0.18.2
# homeassistant.components.devolo_home_network
-devolo-plc-api==1.2.0
+devolo-plc-api==1.3.1
# homeassistant.components.directv
directv==0.4.0
@@ -493,9 +496,15 @@ doorbirdpy==2.1.0
# homeassistant.components.dsmr
dsmr_parser==0.33
+# homeassistant.components.dwd_weather_warnings
+dwdwfsapi==1.0.6
+
# homeassistant.components.dynalite
dynalite_devices==0.1.47
+# homeassistant.components.dynalite
+dynalite_panel==0.0.4
+
# homeassistant.components.rainforest_eagle
eagle100==0.1.1
@@ -624,11 +633,15 @@ goalzero==0.2.1
goodwe==0.2.31
# homeassistant.components.google_mail
+# homeassistant.components.youtube
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub
google-cloud-pubsub==2.13.11
+# homeassistant.components.google_generative_ai_conversation
+google-generativeai==0.1.0rc2
+
# homeassistant.components.nest
google-nest-sdm==2.2.4
@@ -654,18 +667,21 @@ growattServer==1.3.0
gspread==5.5.0
# homeassistant.components.profiler
-guppy3==3.1.2;python_version<'3.11'
+guppy3==3.1.3
# homeassistant.components.iaqualink
h2==4.1.0
# homeassistant.components.generic
# homeassistant.components.stream
-ha-av==10.0.0
+ha-av==10.1.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.1.0
+# homeassistant.components.iotawatt
+ha-iotawattpy==0.1.1
+
# homeassistant.components.philips_js
ha-philipsjs==3.0.0
@@ -673,7 +689,7 @@ ha-philipsjs==3.0.0
habitipy==0.2.0
# homeassistant.components.cloud
-hass-nabucasa==0.66.2
+hass-nabucasa==0.67.1
# homeassistant.components.conversation
hassil==1.0.6
@@ -700,10 +716,10 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
-home-assistant-frontend==20230503.3
+home-assistant-frontend==20230607.0
# homeassistant.components.conversation
-home-assistant-intents==2023.4.26
+home-assistant-intents==2023.6.5
# homeassistant.components.home_connect
homeconnect==0.7.2
@@ -736,7 +752,7 @@ ical==4.5.1
icmplib==3.0
# homeassistant.components.network
-ifaddr==0.1.7
+ifaddr==0.2.0
# homeassistant.components.influxdb
influxdb-client==1.24.0
@@ -753,9 +769,6 @@ insteon-frontend-home-assistant==0.3.5
# homeassistant.components.intellifire
intellifire4py==2.2.2
-# homeassistant.components.iotawatt
-iotawattpy==0.1.0
-
# homeassistant.components.gogogate2
ismartgate==5.0.0
@@ -777,6 +790,9 @@ justnimbus==0.6.0
# homeassistant.components.kegtron
kegtron-ble==0.4.0
+# homeassistant.components.knx
+knx_frontend==2023.5.31.141540
+
# homeassistant.components.konnected
konnected==1.2.0
@@ -784,7 +800,7 @@ konnected==1.2.0
krakenex==2.1.0
# homeassistant.components.lacrosse_view
-lacrosse-view==0.0.9
+lacrosse-view==1.0.1
# homeassistant.components.laundrify
laundrify_aio==1.1.2
@@ -832,7 +848,7 @@ mcstatus==6.0.0
meater-python==0.0.8
# homeassistant.components.melnor
-melnor-bluetooth==0.0.20
+melnor-bluetooth==0.0.24
# homeassistant.components.meteo_france
meteofrance-api==1.2.0
@@ -862,7 +878,7 @@ moehlenhoff-alpha2==1.3.0
mopeka_iot_ble==0.4.1
# homeassistant.components.motion_blinds
-motionblinds==0.6.17
+motionblinds==0.6.18
# homeassistant.components.motioneye
motioneye-client==0.3.14
@@ -904,7 +920,7 @@ nextcord==2.0.0a8
nextdns==1.4.0
# homeassistant.components.nibe_heatpump
-nibe==2.1.4
+nibe==2.2.0
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@@ -945,7 +961,7 @@ omnilogic==0.4.5
ondilo==0.2.0
# homeassistant.components.onvif
-onvif-zeep-async==3.1.7
+onvif-zeep-async==3.1.9
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -1084,13 +1100,13 @@ pyMetEireann==2021.8.0
# homeassistant.components.met
# homeassistant.components.norway_air
-pyMetno==0.9.0
+pyMetno==0.10.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.30.1
# homeassistant.components.tibber
-pyTibber==0.27.1
+pyTibber==0.27.2
# homeassistant.components.dlink
pyW215==0.7.0
@@ -1115,7 +1131,7 @@ pyatag==0.3.5.3
pyatmo==7.5.0
# homeassistant.components.apple_tv
-pyatv==0.11.0
+pyatv==0.12.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1144,11 +1160,14 @@ pycomfoconnect==0.5.1
# homeassistant.components.coolmaster
pycoolmasternet-async==0.1.5
+# homeassistant.components.microsoft
+pycsspeechtts==1.0.8
+
# homeassistant.components.daikin
-pydaikin==2.9.0
+pydaikin==2.9.1
# homeassistant.components.deconz
-pydeconz==111
+pydeconz==112
# homeassistant.components.dexcom
pydexcom==0.2.3
@@ -1165,6 +1184,9 @@ pyefergy==22.1.1
# homeassistant.components.eight_sleep
pyeight==0.3.2
+# homeassistant.components.electrasmart
+pyelectra==1.2.0
+
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1172,10 +1194,10 @@ pyeverlights==0.1.0
pyevilgenius==2.0.0
# homeassistant.components.ezviz
-pyezviz==0.2.0.9
+pyezviz==0.2.0.12
# homeassistant.components.fibaro
-pyfibaro==0.7.0
+pyfibaro==0.7.1
# homeassistant.components.fido
pyfido==2.1.2
@@ -1205,7 +1227,7 @@ pyfronius==0.7.1
pyfttt==0.3
# homeassistant.components.hvv_departures
-pygti==0.9.3
+pygti==0.9.4
# homeassistant.components.version
pyhaversion==22.8.0
@@ -1232,7 +1254,7 @@ pyinsteon==1.4.2
pyipma==3.0.6
# homeassistant.components.ipp
-pyipp==0.12.1
+pyipp==0.13.0
# homeassistant.components.iqvia
pyiqvia==2022.04.0
@@ -1243,6 +1265,9 @@ pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.1.14
+# homeassistant.components.jvc_projector
+pyjvcprojector==1.0.6
+
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1
@@ -1268,7 +1293,7 @@ pykulersky==0.5.2
pylast==5.1.0
# homeassistant.components.launch_library
-pylaunches==1.3.0
+pylaunches==1.4.0
# homeassistant.components.forked_daapd
pylibrespot-java==0.1.1
@@ -1322,7 +1347,7 @@ pynina==0.3.0
pynobo==1.6.0
# homeassistant.components.nuki
-pynuki==1.6.1
+pynuki==1.6.2
# homeassistant.components.nut
pynut2==2.1.2
@@ -1357,7 +1382,7 @@ pyotgw==2.1.3
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.7.8
+pyoverkiz==1.7.9
# homeassistant.components.openweathermap
pyowm==3.2.0
@@ -1366,7 +1391,7 @@ pyowm==3.2.0
pyownet==0.10.0.post1
# homeassistant.components.lcn
-pypck==0.7.16
+pypck==0.7.17
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -1414,7 +1439,7 @@ pyrympro==0.0.7
pysabnzbd==1.1.1
# homeassistant.components.sensibo
-pysensibo==1.0.25
+pysensibo==1.0.28
# homeassistant.components.serial
# homeassistant.components.zha
@@ -1446,7 +1471,7 @@ pysmartapp==0.3.3
pysmartthings==0.7.6
# homeassistant.components.edl21
-pysml==0.0.10
+pysml==0.0.12
# homeassistant.components.snmp
pysnmplib==5.0.21
@@ -1461,10 +1486,10 @@ pysoma==0.0.12
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
-pysqueezebox==0.6.1
+pysqueezebox==0.6.3
# homeassistant.components.switchbee
-pyswitchbee==1.7.19
+pyswitchbee==1.8.0
# homeassistant.components.syncthru
pysyncthru==0.7.10
@@ -1507,7 +1532,7 @@ python-nest==4.2.0
# homeassistant.components.otbr
# homeassistant.components.thread
-python-otbr-api==1.0.9
+python-otbr-api==2.1.0
# homeassistant.components.picnic
python-picnic-api==1.1.0
@@ -1516,7 +1541,7 @@ python-picnic-api==1.1.0
python-qbittorrent==0.4.2
# homeassistant.components.roborock
-python-roborock==0.8.3
+python-roborock==0.23.4
# homeassistant.components.smarttub
python-smarttub==0.0.33
@@ -1525,7 +1550,7 @@ python-smarttub==0.0.33
python-songpal==0.15.2
# homeassistant.components.tado
-python-tado==0.12.0
+python-tado==0.15.0
# homeassistant.components.telegram_bot
python-telegram-bot==13.1
@@ -1548,13 +1573,13 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==0.2.3
+pytrafikverket==0.3.3
# homeassistant.components.usb
pyudev==0.23.2
# homeassistant.components.unifiprotect
-pyunifiprotect==4.9.0
+pyunifiprotect==4.10.1
# homeassistant.components.uptimerobot
pyuptimerobot==22.2.0
@@ -1602,7 +1627,7 @@ radios==0.1.1
radiotherm==2.1.0
# homeassistant.components.rapt_ble
-rapt-ble==0.1.0
+rapt-ble==0.1.1
# homeassistant.components.rainmachine
regenmaschine==2023.05.1
@@ -1611,7 +1636,7 @@ regenmaschine==2023.05.1
renault-api==0.1.13
# homeassistant.components.reolink
-reolink-aio==0.5.15
+reolink-aio==0.5.16
# homeassistant.components.python_script
restrictedpython==6.0
@@ -1623,7 +1648,7 @@ rflink==0.0.65
ring_doorbell==0.7.2
# homeassistant.components.roku
-rokuecp==0.17.1
+rokuecp==0.18.0
# homeassistant.components.roomba
roombapy==1.6.8
@@ -1647,7 +1672,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.5.0
+samsungtvws[async,encrypted]==2.6.0
# homeassistant.components.dhcp
scapy==2.5.0
@@ -1672,7 +1697,7 @@ sensorpro-ble==0.5.3
sensorpush-ble==1.5.5
# homeassistant.components.sentry
-sentry-sdk==1.20.0
+sentry-sdk==1.23.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.6
@@ -1687,7 +1712,7 @@ simplehound==0.3
simplepush==2.1.1
# homeassistant.components.simplisafe
-simplisafe-python==2023.04.0
+simplisafe-python==2023.05.0
# homeassistant.components.slack
slackclient==2.5.0
@@ -1699,7 +1724,7 @@ smart-meter-texas==0.4.7
smhi-pkg==1.0.16
# homeassistant.components.snapcast
-snapcast==2.3.2
+snapcast==2.3.3
# homeassistant.components.sonos
soco==0.29.1
@@ -1714,7 +1739,7 @@ solax==0.3.0
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonos
-sonos-websocket==0.1.1
+sonos-websocket==0.1.2
# homeassistant.components.marytts
speak2mary==1.4.0
@@ -1730,7 +1755,7 @@ spotipy==2.23.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==2.0.12
+sqlalchemy==2.0.15
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -1829,7 +1854,7 @@ twentemilieu==1.0.0
twilio==6.32.0
# homeassistant.components.twitch
-twitchAPI==2.5.2
+twitchAPI==3.10.0
# homeassistant.components.ukraine_alarm
uasiren==0.0.1
@@ -1867,7 +1892,7 @@ velbus-aio==2023.2.0
venstarcolortouch==0.19
# homeassistant.components.vilfo
-vilfo-api-client==0.3.2
+vilfo-api-client==0.4.1
# homeassistant.components.voip
voip-utils==0.0.7
@@ -1922,10 +1947,13 @@ wyoming==0.0.1
xbox-webapi==2.0.11
# homeassistant.components.xiaomi_ble
-xiaomi-ble==0.17.0
+xiaomi-ble==0.17.2
# homeassistant.components.knx
-xknx==2.9.0
+xknx==2.10.0
+
+# homeassistant.components.knx
+xknxproject==3.1.0
# homeassistant.components.bluesound
# homeassistant.components.fritz
@@ -1949,7 +1977,7 @@ yalexs==1.5.1
yeelight==0.7.10
# homeassistant.components.yolink
-yolink-api==0.2.8
+yolink-api==0.2.9
# homeassistant.components.youless
youless-api==1.0.1
@@ -1958,13 +1986,13 @@ youless-api==1.0.1
zamg==0.2.2
# homeassistant.components.zeroconf
-zeroconf==0.58.2
+zeroconf==0.64.0
# homeassistant.components.zeversolar
zeversolar==0.3.1
# homeassistant.components.zha
-zha-quirks==0.0.99
+zha-quirks==0.0.100
# homeassistant.components.zha
zigpy-deconz==0.21.0
@@ -1982,7 +2010,7 @@ zigpy-znp==0.11.1
zigpy==0.55.0
# homeassistant.components.zwave_js
-zwave-js-server-python==0.48.1
+zwave-js-server-python==0.49.0
# homeassistant.components.zwave_me
-zwave_me_ws==0.4.2
+zwave_me_ws==0.4.3
diff --git a/script/bootstrap b/script/bootstrap
index 0ea2b1476cda..46a5975eff50 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -8,5 +8,5 @@ cd "$(dirname "$0")/.."
echo "Installing development dependencies..."
python3 -m pip install wheel --constraint homeassistant/package_constraints.txt --upgrade
-python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver --upgrade
-python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --use-deprecated legacy-resolver --upgrade
+python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade
+python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index f3479d477891..b51ddb463074 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -24,7 +24,6 @@ COMMENT_REQUIREMENTS = (
"atenpdu", # depends on pysnmp which is not maintained at this time
"avea", # depends on bluepy
"avion",
- "azure-servicebus", # depends on uamqp, which requires OpenSSL 1.1
"beacontools",
"beewi_smartclim", # depends on bluepy
"bluepy",
@@ -157,13 +156,9 @@ matplotlib==3.6.1
# cryptography 40.0.1 is installed with botocore
pyOpenSSL>=23.1.0
-# uamqp newer versions we currently can't build for armv7/armhf
-# Limit this to Python 3.10, to not block Python 3.11 dev for now
-uamqp==1.6.0;python_version<'3.11'
-
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==4.22.3
+protobuf==4.23.1
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
@@ -184,6 +179,14 @@ pysnmplib==5.0.21
# pysnmp is no longer maintained and does not work with newer
# python
pysnmp==1000000000.0.0
+
+# pyminiaudio 1.58 is missing files in the package
+# https://github.com/irmen/pyminiaudio/issues/67
+miniaudio==1.57
+
+# The get-mac package has been replaced with getmac. Installing get-mac alongside getmac
+# breaks getmac due to them both sharing the same python package name inside 'getmac'.
+get-mac==1000000000.0.0
"""
IGNORE_PRE_COMMIT_HOOK_ID = (
diff --git a/script/microsoft_tts.py b/script/microsoft_tts.py
new file mode 100644
index 000000000000..bc0b577c97c5
--- /dev/null
+++ b/script/microsoft_tts.py
@@ -0,0 +1,25 @@
+"""Helper script to update supported languages for Microsoft text-to-speech (TTS)."""
+from pathlib import Path
+
+from lxml import html
+import requests
+
+from .hassfest.serializer import format_python_namespace
+
+URL = "https://docs.microsoft.com/en-us/azure/cognitive-services/speech-service/language-support"
+XPATH_QUERY = "//section[@data-tab='tts']/table[1]/tbody/tr/td[1]/code/text()"
+
+req = requests.get(URL)
+req.raise_for_status()
+tree = html.fromstring(req.content)
+supported_languages_raw = tree.xpath(XPATH_QUERY)
+supported_languages = {s.lower() for s in supported_languages_raw}
+
+Path("homeassistant/generated/microsoft_tts.py").write_text(
+ format_python_namespace(
+ {
+ "SUPPORTED_LANGUAGES": supported_languages,
+ },
+ generator="script.microsoft_tts",
+ )
+)
diff --git a/script/pip_check b/script/pip_check
deleted file mode 100755
index cbbe7ffeeaef..000000000000
--- a/script/pip_check
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/bash
-PIP_CACHE=$1
-
-# Number of existing dependency conflicts
-# Update if a PR resolves one!
-DEPENDENCY_CONFLICTS=3
-
-PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE)
-LINE_COUNT=$(echo "$PIP_CHECK" | wc -l)
-echo "$PIP_CHECK"
-
-if [[ $((LINE_COUNT)) -gt $DEPENDENCY_CONFLICTS ]]
-then
- echo "------"
- echo "Requirements change added another dependency conflict."
- echo "Make sure to check the 'pip check' output above!"
- echo "Expected $DEPENDENCY_CONFLICTS conflicts, got $LINE_COUNT."
- exit 1
-elif [[ $((LINE_COUNT)) -lt $DEPENDENCY_CONFLICTS ]]
-then
- echo "------"
- echo "It seems like this PR resolves $((
- DEPENDENCY_CONFLICTS - LINE_COUNT)) dependency conflicts."
- echo "Please update the 'DEPENDENCY_CONFLICTS' constant "
- echo "in 'script/pip_check' to help prevent regressions."
- echo "Update it to: $((LINE_COUNT))"
- exit 1
-fi
diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py
index 1fd8810bd88b..4179bf5248a4 100644
--- a/script/scaffold/templates/device_trigger/integration/device_trigger.py
+++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py
@@ -5,10 +5,6 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components.automation import (
- AutomationActionType,
- AutomationTriggerInfo,
-)
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import state as state_trigger
from homeassistant.const import (
@@ -22,6 +18,7 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN
@@ -73,8 +70,8 @@ async def async_get_triggers(
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
- action: AutomationActionType,
- automation_info: AutomationTriggerInfo,
+ action: TriggerActionType,
+ trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
# TODO Implement your own logic to attach triggers.
@@ -92,5 +89,5 @@ async def async_attach_trigger(
}
state_config = await state_trigger.async_validate_trigger_config(hass, state_config)
return await state_trigger.async_attach_trigger(
- hass, state_config, action, automation_info, platform_type="device"
+ hass, state_config, action, trigger_info, platform_type="device"
)
diff --git a/script/setup b/script/setup
index 782eb5106546..e68ec72cbbae 100755
--- a/script/setup
+++ b/script/setup
@@ -24,7 +24,7 @@ fi
script/bootstrap
pre-commit install
-python3 -m pip install -e . --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver
+python3 -m pip install -e . --constraint homeassistant/package_constraints.txt
python3 -m script.translations develop --all
hass --script ensure_config -c config
diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py
index 53d1f0a9fa76..a098eea28e07 100644
--- a/tests/auth/providers/test_trusted_networks.py
+++ b/tests/auth/providers/test_trusted_networks.py
@@ -116,6 +116,32 @@ def manager_bypass_login(hass, store, provider_bypass_login):
)
+async def test_config_schema():
+ """Test CONFIG_SCHEMA."""
+ # Valid configuration
+ tn_auth.CONFIG_SCHEMA(
+ {
+ "type": "trusted_networks",
+ "trusted_networks": ["192.168.0.1"],
+ "trusted_users": {
+ "192.168.0.1": [
+ "a1ab982744b64757bf80515589258924",
+ {"group": "system-group"},
+ ]
+ },
+ }
+ )
+ # Wrong user id format
+ with pytest.raises(vol.Invalid):
+ tn_auth.CONFIG_SCHEMA(
+ {
+ "type": "trusted_networks",
+ "trusted_networks": ["192.168.0.1"],
+ "trusted_users": {"192.168.0.1": ["abcde"]},
+ }
+ )
+
+
async def test_trusted_networks_credentials(manager, provider) -> None:
"""Test trusted_networks credentials related functions."""
owner = await manager.async_create_user("test-owner")
diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py
index 83c08dd73ee8..3cead230b1b8 100644
--- a/tests/auth/test_init.py
+++ b/tests/auth/test_init.py
@@ -1,7 +1,7 @@
"""Tests for the Home Assistant auth module."""
from datetime import timedelta
from typing import Any
-from unittest.mock import Mock, patch
+from unittest.mock import patch
from freezegun import freeze_time
import jwt
@@ -31,10 +31,8 @@ from tests.common import (
@pytest.fixture
-def mock_hass(event_loop):
+def mock_hass(hass: HomeAssistant) -> HomeAssistant:
"""Home Assistant mock with minimum amount of data set to make it work with auth."""
- hass = Mock()
- hass.config.skip_pip = True
return hass
diff --git a/tests/common.py b/tests/common.py
index 632294a50fbc..ca164fcaaf87 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -29,7 +29,7 @@ from homeassistant.auth import (
providers as auth_providers,
)
from homeassistant.auth.permissions import system_policies
-from homeassistant.components import device_automation
+from homeassistant.components import device_automation, persistent_notification as pn
from homeassistant.components.device_automation import ( # noqa: F401
_async_get_device_automation_capabilities as async_get_device_automation_capabilities,
)
@@ -61,6 +61,7 @@ from homeassistant.helpers import (
issue_registry as ir,
recorder as recorder_helper,
restore_state,
+ restore_state as rs,
storage,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -68,7 +69,7 @@ from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.setup import setup_component
from homeassistant.util.async_ import run_callback_threadsafe
-import homeassistant.util.dt as date_util
+import homeassistant.util.dt as dt_util
from homeassistant.util.json import (
JsonArrayType,
JsonObjectType,
@@ -251,12 +252,20 @@ async def async_test_home_assistant(event_loop, load_registries=True):
# Load the registries
entity.async_setup(hass)
if load_registries:
- with patch("homeassistant.helpers.storage.Store.async_load", return_value=None):
+ with patch(
+ "homeassistant.helpers.storage.Store.async_load", return_value=None
+ ), patch(
+ "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump",
+ return_value=None,
+ ), patch(
+ "homeassistant.helpers.restore_state.start.async_at_start"
+ ):
await asyncio.gather(
ar.async_load(hass),
dr.async_load(hass),
er.async_load(hass),
ir.async_load(hass),
+ rs.async_load(hass),
)
hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None
@@ -357,7 +366,7 @@ def async_fire_time_changed_exact(
if datetime_ is None:
utc_datetime = datetime.now(timezone.utc)
else:
- utc_datetime = date_util.as_utc(datetime_)
+ utc_datetime = dt_util.as_utc(datetime_)
_async_fire_time_changed(hass, utc_datetime, fire_all)
@@ -379,7 +388,7 @@ def async_fire_time_changed(
if datetime_ is None:
utc_datetime = datetime.now(timezone.utc)
else:
- utc_datetime = date_util.as_utc(datetime_)
+ utc_datetime = dt_util.as_utc(datetime_)
if utc_datetime.microsecond < 500000:
# Allow up to 500000 microseconds to be added to the time
@@ -395,7 +404,7 @@ def async_fire_time_changed(
def _async_fire_time_changed(
hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool
) -> None:
- timestamp = date_util.utc_to_timestamp(utc_datetime)
+ timestamp = dt_util.utc_to_timestamp(utc_datetime)
for task in list(hass.loop._scheduled):
if not isinstance(task, asyncio.TimerHandle):
continue
@@ -1010,9 +1019,9 @@ def init_recorder_component(hass, add_config=None, db_url="sqlite://"):
def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None:
"""Mock the DATA_RESTORE_CACHE."""
- key = restore_state.DATA_RESTORE_STATE_TASK
+ key = restore_state.DATA_RESTORE_STATE
data = restore_state.RestoreStateData(hass)
- now = date_util.utcnow()
+ now = dt_util.utcnow()
last_states = {}
for state in states:
@@ -1037,9 +1046,9 @@ def mock_restore_cache_with_extra_data(
hass: HomeAssistant, states: Sequence[tuple[State, Mapping[str, Any]]]
) -> None:
"""Mock the DATA_RESTORE_CACHE."""
- key = restore_state.DATA_RESTORE_STATE_TASK
+ key = restore_state.DATA_RESTORE_STATE
data = restore_state.RestoreStateData(hass)
- now = date_util.utcnow()
+ now = dt_util.utcnow()
last_states = {}
for state, extra_data in states:
@@ -1060,6 +1069,26 @@ def mock_restore_cache_with_extra_data(
hass.data[key] = data
+async def async_mock_restore_state_shutdown_restart(
+ hass: HomeAssistant,
+) -> restore_state.RestoreStateData:
+ """Mock shutting down and saving restore state and restoring."""
+ data = restore_state.async_get(hass)
+ await data.async_dump_states()
+ await async_mock_load_restore_state_from_storage(hass)
+ return data
+
+
+async def async_mock_load_restore_state_from_storage(
+ hass: HomeAssistant,
+) -> None:
+ """Mock loading restore state from storage.
+
+ hass_storage must already be mocked.
+ """
+ await restore_state.async_get(hass).async_load()
+
+
class MockEntity(entity.Entity):
"""Mock Entity class."""
@@ -1396,3 +1425,11 @@ def raise_contains_mocks(val: Any) -> None:
if isinstance(val, list):
for dict_value in val:
raise_contains_mocks(dict_value)
+
+
+@callback
+def async_get_persistent_notifications(
+ hass: HomeAssistant,
+) -> dict[str, pn.Notification]:
+ """Get the current persistent notifications."""
+ return pn._async_get_or_create_notifications(hass)
diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py
index fd6441b4346a..3c7f81450c65 100644
--- a/tests/components/accuweather/__init__.py
+++ b/tests/components/accuweather/__init__.py
@@ -41,7 +41,7 @@ async def init_integration(
"homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
return_value=current,
), patch(
- "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast",
return_value=forecast,
), patch(
"homeassistant.components.accuweather.AccuWeather.requests_remaining",
diff --git a/tests/components/accuweather/fixtures/current_conditions_data.json b/tests/components/accuweather/fixtures/current_conditions_data.json
index 020345bf8fa3..97f42dcc4110 100644
--- a/tests/components/accuweather/fixtures/current_conditions_data.json
+++ b/tests/components/accuweather/fixtures/current_conditions_data.json
@@ -1,4 +1,5 @@
{
+ "WeatherText": "Sunny",
"WeatherIcon": 1,
"HasPrecipitation": false,
"PrecipitationType": null,
diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json
index 1b432fbd0757..a7d57af113a4 100644
--- a/tests/components/accuweather/fixtures/forecast_data.json
+++ b/tests/components/accuweather/fixtures/forecast_data.json
@@ -137,6 +137,11 @@
"HoursOfSnowDay": 0.0,
"HoursOfIceDay": 0.0,
"CloudCoverDay": 58,
+ "SolarIrradianceDay": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
"IconNight": 41,
"IconPhraseNight": "Partly cloudy w/ t-storms",
"HasPrecipitationNight": true,
@@ -197,7 +202,12 @@
"HoursOfRainNight": 1.0,
"HoursOfSnowNight": 0.0,
"HoursOfIceNight": 0.0,
- "CloudCoverNight": 65
+ "CloudCoverNight": 65,
+ "SolarIrradianceNight": {
+ "Value": 271.6,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ }
},
{
"Date": "2020-07-27T07:00:00+02:00",
@@ -335,6 +345,11 @@
"HoursOfSnowDay": 0.0,
"HoursOfIceDay": 0.0,
"CloudCoverDay": 52,
+ "SolarIrradianceDay": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
"IconNight": 36,
"IconPhraseNight": "Intermittent clouds",
"HasPrecipitationNight": false,
@@ -393,7 +408,12 @@
"HoursOfRainNight": 0.0,
"HoursOfSnowNight": 0.0,
"HoursOfIceNight": 0.0,
- "CloudCoverNight": 63
+ "CloudCoverNight": 63,
+ "SolarIrradianceNight": {
+ "Value": 271.6,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ }
},
{
"Date": "2020-07-28T07:00:00+02:00",
@@ -531,6 +551,11 @@
"HoursOfSnowDay": 0.0,
"HoursOfIceDay": 0.0,
"CloudCoverDay": 65,
+ "SolarIrradianceDay": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
"IconNight": 36,
"IconPhraseNight": "Intermittent clouds",
"HasPrecipitationNight": false,
@@ -589,7 +614,12 @@
"HoursOfRainNight": 0.0,
"HoursOfSnowNight": 0.0,
"HoursOfIceNight": 0.0,
- "CloudCoverNight": 53
+ "CloudCoverNight": 53,
+ "SolarIrradianceNight": {
+ "Value": 271.6,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ }
},
{
"Date": "2020-07-29T07:00:00+02:00",
@@ -727,6 +757,11 @@
"HoursOfSnowDay": 0.0,
"HoursOfIceDay": 0.0,
"CloudCoverDay": 45,
+ "SolarIrradianceDay": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
"IconNight": 34,
"IconPhraseNight": "Mostly clear",
"HasPrecipitationNight": false,
@@ -785,7 +820,12 @@
"HoursOfRainNight": 0.0,
"HoursOfSnowNight": 0.0,
"HoursOfIceNight": 0.0,
- "CloudCoverNight": 27
+ "CloudCoverNight": 27,
+ "SolarIrradianceNight": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ }
},
{
"Date": "2020-07-30T07:00:00+02:00",
@@ -923,6 +963,11 @@
"HoursOfSnowDay": 0.0,
"HoursOfIceDay": 0.0,
"CloudCoverDay": 50,
+ "SolarIrradianceDay": {
+ "Value": 7447.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
"IconNight": 34,
"IconPhraseNight": "Mostly clear",
"HasPrecipitationNight": false,
@@ -981,6 +1026,11 @@
"HoursOfRainNight": 0.0,
"HoursOfSnowNight": 0.0,
"HoursOfIceNight": 0.0,
- "CloudCoverNight": 13
+ "CloudCoverNight": 13,
+ "SolarIrradianceNight": {
+ "Value": 276.1,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ }
}
]
diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py
index 4d8d83a2a2f2..35b6e095c0f8 100644
--- a/tests/components/accuweather/test_config_flow.py
+++ b/tests/components/accuweather/test_config_flow.py
@@ -156,7 +156,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
"accuweather/current_conditions_data.json"
),
), patch(
- "homeassistant.components.accuweather.AccuWeather.async_get_forecast"
+ "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast"
), patch(
"homeassistant.components.accuweather.AccuWeather.requests_remaining",
new_callable=PropertyMock,
diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py
index 767bbd9953a4..98be70d9ec67 100644
--- a/tests/components/accuweather/test_diagnostics.py
+++ b/tests/components/accuweather/test_diagnostics.py
@@ -19,7 +19,7 @@ async def test_entry_diagnostics(
"current_conditions_data.json", "accuweather"
)
- coordinator_data["forecast"] = {}
+ coordinator_data["forecast"] = []
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py
index 7123d5ef8177..c7f79b487b5e 100644
--- a/tests/components/accuweather/test_init.py
+++ b/tests/components/accuweather/test_init.py
@@ -104,7 +104,7 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None:
"homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
return_value=current,
) as mock_current, patch(
- "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast",
return_value=forecast,
) as mock_forecast:
assert mock_current.call_count == 0
diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py
index 37e58504efeb..35f86bdb039f 100644
--- a/tests/components/accuweather/test_sensor.py
+++ b/tests/components/accuweather/test_sensor.py
@@ -2,11 +2,10 @@
from datetime import timedelta
from unittest.mock import PropertyMock, patch
-from homeassistant.components.accuweather.const import ATTRIBUTION, DOMAIN
+from homeassistant.components.accuweather.const import ATTRIBUTION
from homeassistant.components.sensor import (
ATTR_OPTIONS,
ATTR_STATE_CLASS,
- DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
@@ -20,6 +19,7 @@ from homeassistant.const import (
PERCENTAGE,
STATE_UNAVAILABLE,
UV_INDEX,
+ UnitOfIrradiance,
UnitOfLength,
UnitOfSpeed,
UnitOfTemperature,
@@ -41,7 +41,9 @@ from tests.common import (
)
-async def test_sensor_without_forecast(hass: HomeAssistant) -> None:
+async def test_sensor_without_forecast(
+ hass: HomeAssistant, entity_registry_enabled_by_default: None
+) -> None:
"""Test states of the sensor without forecast."""
await init_integration(hass)
registry = er.async_get(hass)
@@ -118,271 +120,6 @@ async def test_sensor_without_forecast(hass: HomeAssistant) -> None:
assert entry
assert entry.unique_id == "0123456-uvindex"
-
-async def test_sensor_with_forecast(hass: HomeAssistant) -> None:
- """Test states of the sensor with forecast."""
- await init_integration(hass, forecast=True)
- registry = er.async_get(hass)
-
- state = hass.states.get("sensor.home_hours_of_sun_0d")
- assert state
- assert state.state == "7.2"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_hours_of_sun_0d")
- assert entry
- assert entry.unique_id == "0123456-hoursofsun-0"
-
- state = hass.states.get("sensor.home_realfeel_temperature_max_0d")
- assert state
- assert state.state == "29.8"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
- assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_realfeel_temperature_max_0d")
- assert entry
-
- state = hass.states.get("sensor.home_realfeel_temperature_min_0d")
- assert state
- assert state.state == "15.1"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
- assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_realfeel_temperature_min_0d")
- assert entry
- assert entry.unique_id == "0123456-realfeeltemperaturemin-0"
-
- state = hass.states.get("sensor.home_thunderstorm_probability_day_0d")
- assert state
- assert state.state == "40"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d")
- assert entry
- assert entry.unique_id == "0123456-thunderstormprobabilityday-0"
-
- state = hass.states.get("sensor.home_thunderstorm_probability_night_0d")
- assert state
- assert state.state == "40"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d")
- assert entry
- assert entry.unique_id == "0123456-thunderstormprobabilitynight-0"
-
- state = hass.states.get("sensor.home_uv_index_0d")
- assert state
- assert state.state == "5"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX
- assert state.attributes.get("level") == "moderate"
- assert state.attributes.get(ATTR_STATE_CLASS) is None
-
- entry = registry.async_get("sensor.home_uv_index_0d")
- assert entry
- assert entry.unique_id == "0123456-uvindex-0"
-
- state = hass.states.get("sensor.home_air_quality_0d")
- assert state
- assert state.state == "good"
- assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_ICON) == "mdi:air-filter"
- assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM
- assert state.attributes.get(ATTR_OPTIONS) == [
- "good",
- "hazardous",
- "high",
- "low",
- "moderate",
- "unhealthy",
- ]
-
- entry = registry.async_get("sensor.home_air_quality_0d")
- assert entry
- assert entry.unique_id == "0123456-airquality-0"
-
-
-async def test_sensor_disabled(hass: HomeAssistant) -> None:
- """Test sensor disabled by default."""
- await init_integration(hass)
- registry = er.async_get(hass)
-
- entry = registry.async_get("sensor.home_apparent_temperature")
- assert entry
- assert entry.unique_id == "0123456-apparenttemperature"
- assert entry.disabled
- assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
-
- # Test enabling entity
- updated_entry = registry.async_update_entity(
- entry.entity_id, **{"disabled_by": None}
- )
-
- assert updated_entry != entry
- assert updated_entry.disabled is False
-
-
-async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None:
- """Test enabling an advanced sensor."""
- registry = er.async_get(hass)
-
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-apparenttemperature",
- suggested_object_id="home_apparent_temperature",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-cloudcover",
- suggested_object_id="home_cloud_cover",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-dewpoint",
- suggested_object_id="home_dew_point",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-realfeeltemperatureshade",
- suggested_object_id="home_realfeel_temperature_shade",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-wetbulbtemperature",
- suggested_object_id="home_wet_bulb_temperature",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-wind",
- suggested_object_id="home_wind",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windchilltemperature",
- suggested_object_id="home_wind_chill_temperature",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windgust",
- suggested_object_id="home_wind_gust",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-cloudcoverday-0",
- suggested_object_id="home_cloud_cover_day_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-cloudcovernight-0",
- suggested_object_id="home_cloud_cover_night_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-grass-0",
- suggested_object_id="home_grass_pollen_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-mold-0",
- suggested_object_id="home_mold_pollen_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-ragweed-0",
- suggested_object_id="home_ragweed_pollen_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-realfeeltemperatureshademax-0",
- suggested_object_id="home_realfeel_temperature_shade_max_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-realfeeltemperatureshademin-0",
- suggested_object_id="home_realfeel_temperature_shade_min_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-tree-0",
- suggested_object_id="home_tree_pollen_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windgustday-0",
- suggested_object_id="home_wind_gust_day_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windgustnight-0",
- suggested_object_id="home_wind_gust_night_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windday-0",
- suggested_object_id="home_wind_day_0d",
- disabled_by=None,
- )
- registry.async_get_or_create(
- SENSOR_DOMAIN,
- DOMAIN,
- "0123456-windnight-0",
- suggested_object_id="home_wind_night_0d",
- disabled_by=None,
- )
-
- await init_integration(hass, forecast=True)
-
state = hass.states.get("sensor.home_apparent_temperature")
assert state
assert state.state == "22.8"
@@ -487,6 +224,101 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None:
assert entry
assert entry.unique_id == "0123456-wind"
+
+async def test_sensor_with_forecast(
+ hass: HomeAssistant, entity_registry_enabled_by_default: None
+) -> None:
+ """Test states of the sensor with forecast."""
+ await init_integration(hass, forecast=True)
+ registry = er.async_get(hass)
+
+ state = hass.states.get("sensor.home_hours_of_sun_0d")
+ assert state
+ assert state.state == "7.2"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_hours_of_sun_0d")
+ assert entry
+ assert entry.unique_id == "0123456-hoursofsun-0"
+
+ state = hass.states.get("sensor.home_realfeel_temperature_max_0d")
+ assert state
+ assert state.state == "29.8"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_max_0d")
+ assert entry
+
+ state = hass.states.get("sensor.home_realfeel_temperature_min_0d")
+ assert state
+ assert state.state == "15.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_min_0d")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperaturemin-0"
+
+ state = hass.states.get("sensor.home_thunderstorm_probability_day_0d")
+ assert state
+ assert state.state == "40"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-thunderstormprobabilityday-0"
+
+ state = hass.states.get("sensor.home_thunderstorm_probability_night_0d")
+ assert state
+ assert state.state == "40"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d")
+ assert entry
+ assert entry.unique_id == "0123456-thunderstormprobabilitynight-0"
+
+ state = hass.states.get("sensor.home_uv_index_0d")
+ assert state
+ assert state.state == "5"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX
+ assert state.attributes.get("level") == "moderate"
+ assert state.attributes.get(ATTR_STATE_CLASS) is None
+
+ entry = registry.async_get("sensor.home_uv_index_0d")
+ assert entry
+ assert entry.unique_id == "0123456-uvindex-0"
+
+ state = hass.states.get("sensor.home_air_quality_0d")
+ assert state
+ assert state.state == "good"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:air-filter"
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM
+ assert state.attributes.get(ATTR_OPTIONS) == [
+ "good",
+ "hazardous",
+ "high",
+ "low",
+ "moderate",
+ "unhealthy",
+ ]
+
state = hass.states.get("sensor.home_cloud_cover_day_0d")
assert state
assert state.state == "58"
@@ -662,6 +494,59 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None:
assert entry
assert entry.unique_id == "0123456-windgustnight-0"
+ entry = registry.async_get("sensor.home_air_quality_0d")
+ assert entry
+ assert entry.unique_id == "0123456-airquality-0"
+
+ state = hass.states.get("sensor.home_solar_irradiance_day_0d")
+ assert state
+ assert state.state == "7447.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny"
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == UnitOfIrradiance.WATTS_PER_SQUARE_METER
+ )
+
+ entry = registry.async_get("sensor.home_solar_irradiance_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-solarirradianceday-0"
+
+ state = hass.states.get("sensor.home_solar_irradiance_night_0d")
+ assert state
+ assert state.state == "271.6"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny"
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == UnitOfIrradiance.WATTS_PER_SQUARE_METER
+ )
+
+ entry = registry.async_get("sensor.home_solar_irradiance_night_0d")
+ assert entry
+ assert entry.unique_id == "0123456-solarirradiancenight-0"
+
+ state = hass.states.get("sensor.home_condition_day_0d")
+ assert state
+ assert (
+ state.state
+ == "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon"
+ )
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+
+ entry = registry.async_get("sensor.home_condition_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-longphraseday-0"
+
+ state = hass.states.get("sensor.home_condition_night_0d")
+ assert state
+ assert state.state == "Partly cloudy"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+
+ entry = registry.async_get("sensor.home_condition_night_0d")
+ assert entry
+ assert entry.unique_id == "0123456-longphrasenight-0"
+
async def test_availability(hass: HomeAssistant) -> None:
"""Ensure that we mark the entities unavailable correctly when service is offline."""
@@ -717,7 +602,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None:
"homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
return_value=current,
) as mock_current, patch(
- "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast",
return_value=forecast,
) as mock_forecast, patch(
"homeassistant.components.accuweather.AccuWeather.requests_remaining",
diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py
index ab4faf73c81f..de1703128ddc 100644
--- a/tests/components/accuweather/test_weather.py
+++ b/tests/components/accuweather/test_weather.py
@@ -74,8 +74,8 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None:
assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
forecast = state.attributes.get(ATTR_FORECAST)[0]
assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy"
- assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 4.8
- assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 58
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 2.5
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 60
assert forecast.get(ATTR_FORECAST_TEMP) == 29.5
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4
assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00"
@@ -141,7 +141,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None:
"homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
return_value=current,
) as mock_current, patch(
- "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast",
return_value=forecast,
) as mock_forecast, patch(
"homeassistant.components.accuweather.AccuWeather.requests_remaining",
diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py
index 37e816b366b0..8f2183d49c5f 100644
--- a/tests/components/advantage_air/test_binary_sensor.py
+++ b/tests/components/advantage_air/test_binary_sensor.py
@@ -5,7 +5,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import (
TEST_SET_RESPONSE,
@@ -88,7 +88,7 @@ async def test_binary_sensor_async_setup_entry(
async_fire_time_changed(
hass,
- dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
+ dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
@@ -110,7 +110,7 @@ async def test_binary_sensor_async_setup_entry(
async_fire_time_changed(
hass,
- dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
+ dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py
index 2a7be320be62..d2c290a97dee 100644
--- a/tests/components/advantage_air/test_sensor.py
+++ b/tests/components/advantage_air/test_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.util import dt
+from homeassistant.util import dt as dt_util
from . import (
TEST_SET_RESPONSE,
@@ -144,7 +144,7 @@ async def test_sensor_platform(
async_fire_time_changed(
hass,
- dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
+ dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py
index caf8cfe13bd8..2c66adcb9746 100644
--- a/tests/components/airzone/test_climate.py
+++ b/tests/components/airzone/test_climate.py
@@ -1,29 +1,43 @@
"""The climate tests for the Airzone platform."""
from unittest.mock import patch
-from aioairzone.common import OperationMode
from aioairzone.const import (
+ API_COOL_SET_POINT,
API_DATA,
- API_MODE,
+ API_HEAT_SET_POINT,
+ API_MAX_TEMP,
+ API_MIN_TEMP,
API_ON,
API_SET_POINT,
+ API_SPEED,
API_SYSTEM_ID,
+ API_SYSTEMS,
API_ZONE_ID,
)
from aioairzone.exceptions import AirzoneError
import pytest
from homeassistant.components.airzone.const import API_TEMPERATURE_STEP
+from homeassistant.components.airzone.coordinator import SCAN_INTERVAL
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
+ ATTR_FAN_MODE,
+ ATTR_FAN_MODES,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
DOMAIN as CLIMATE_DOMAIN,
+ FAN_AUTO,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
@@ -37,8 +51,16 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.dt import utcnow
-from .util import async_init_integration
+from .util import (
+ HVAC_MOCK,
+ HVAC_SYSTEMS_MOCK,
+ HVAC_WEBSERVER_MOCK,
+ async_init_integration,
+)
+
+from tests.common import async_fire_time_changed
async def test_airzone_create_climates(hass: HomeAssistant) -> None:
@@ -50,6 +72,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.OFF
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 36
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.2
+ assert state.attributes.get(ATTR_FAN_MODE) is None
+ assert state.attributes.get(ATTR_FAN_MODES) is None
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.OFF,
@@ -67,6 +91,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.HEAT
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 35
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.8
+ assert state.attributes.get(ATTR_FAN_MODE) is None
+ assert state.attributes.get(ATTR_FAN_MODES) is None
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.IDLE
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.OFF,
@@ -84,6 +110,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.OFF
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 40
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.5
+ assert state.attributes.get(ATTR_FAN_MODE) is None
+ assert state.attributes.get(ATTR_FAN_MODES) is None
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.OFF,
@@ -101,6 +129,12 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.HEAT
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 39
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.1
+ assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO
+ assert state.attributes.get(ATTR_FAN_MODES) == [
+ FAN_AUTO,
+ FAN_LOW,
+ FAN_HIGH,
+ ]
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.HEATING
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.OFF,
@@ -118,6 +152,13 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.OFF
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 34
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 19.6
+ assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO
+ assert state.attributes.get(ATTR_FAN_MODES) == [
+ FAN_AUTO,
+ FAN_LOW,
+ FAN_MEDIUM,
+ FAN_HIGH,
+ ]
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.OFF,
@@ -135,6 +176,14 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.OFF
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 62
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.3
+ assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO
+ assert state.attributes.get(ATTR_FAN_MODES) == [
+ FAN_AUTO,
+ FAN_LOW,
+ FAN_MEDIUM,
+ "75%",
+ FAN_HIGH,
+ ]
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.HEAT_COOL,
@@ -149,6 +198,15 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.state == HVACMode.HEAT_COOL
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.7
+ assert state.attributes.get(ATTR_FAN_MODE) == "40%"
+ assert state.attributes.get(ATTR_FAN_MODES) == [
+ FAN_AUTO,
+ FAN_LOW,
+ "40%",
+ FAN_MEDIUM,
+ "80%",
+ FAN_HIGH,
+ ]
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING
assert state.attributes.get(ATTR_HVAC_MODES) == [
HVACMode.FAN_ONLY,
@@ -163,6 +221,27 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP
assert state.attributes.get(ATTR_TEMPERATURE) == 22.8
+ HVAC_MOCK_CHANGED = {**HVAC_MOCK}
+ HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MAX_TEMP] = 25
+ HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MIN_TEMP] = 10
+
+ with patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac",
+ return_value=HVAC_MOCK_CHANGED,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
+ return_value=HVAC_SYSTEMS_MOCK,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
+ return_value=HVAC_WEBSERVER_MOCK,
+ ):
+ async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("climate.salon")
+ assert state.attributes.get(ATTR_MAX_TEMP) == 25
+ assert state.attributes.get(ATTR_MIN_TEMP) == 10
+
async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None:
"""Test turning on."""
@@ -255,7 +334,6 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None:
{
API_SYSTEM_ID: 1,
API_ZONE_ID: 1,
- API_MODE: OperationMode.COOLING.value,
API_ON: 1,
}
]
@@ -363,6 +441,39 @@ async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None
assert state.state == HVACMode.OFF
+async def test_airzone_climate_set_fan_mode(hass: HomeAssistant) -> None:
+ """Test setting the target temperature."""
+
+ HVAC_MOCK = {
+ API_DATA: [
+ {
+ API_SYSTEM_ID: 1,
+ API_ZONE_ID: 1,
+ API_SPEED: 2,
+ }
+ ]
+ }
+
+ await async_init_integration(hass)
+
+ with patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.put_hvac",
+ return_value=HVAC_MOCK,
+ ):
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_FAN_MODE,
+ {
+ ATTR_ENTITY_ID: "climate.salon",
+ ATTR_FAN_MODE: FAN_MEDIUM,
+ },
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.salon")
+ assert state.attributes.get(ATTR_FAN_MODE) == FAN_MEDIUM
+
+
async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None:
"""Test setting the target temperature."""
@@ -417,3 +528,39 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None:
state = hass.states.get("climate.dorm_2")
assert state.attributes.get(ATTR_TEMPERATURE) == 19.5
+
+
+async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None:
+ """Test setting the target temperature range."""
+
+ HVAC_MOCK = {
+ API_DATA: [
+ {
+ API_SYSTEM_ID: 3,
+ API_ZONE_ID: 1,
+ API_COOL_SET_POINT: 68.0,
+ API_HEAT_SET_POINT: 77.0,
+ }
+ ]
+ }
+
+ await async_init_integration(hass)
+
+ with patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.put_hvac",
+ return_value=HVAC_MOCK,
+ ):
+ await hass.services.async_call(
+ CLIMATE_DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ {
+ ATTR_ENTITY_ID: "climate.dkn_plus",
+ ATTR_TARGET_TEMP_HIGH: 25.0,
+ ATTR_TARGET_TEMP_LOW: 20.0,
+ },
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.dkn_plus")
+ assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0
+ assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0
diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py
index 2d89d0b556e8..5460272e74e9 100644
--- a/tests/components/airzone/test_config_flow.py
+++ b/tests/components/airzone/test_config_flow.py
@@ -45,6 +45,9 @@ async def test_form(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
return_value=HVAC_WEBSERVER_MOCK,
@@ -88,6 +91,9 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None:
) as mock_hvac, patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
side_effect=InvalidMethod,
@@ -184,6 +190,9 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
return_value=HVAC_WEBSERVER_MOCK,
@@ -259,6 +268,9 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
return_value=HVAC_WEBSERVER_MOCK,
@@ -309,6 +321,9 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None:
) as mock_hvac, patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
side_effect=InvalidMethod,
diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py
index c2ee5c0cbca6..bcfdad8ead87 100644
--- a/tests/components/airzone/test_coordinator.py
+++ b/tests/components/airzone/test_coordinator.py
@@ -10,7 +10,7 @@ from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
-from .util import CONFIG, HVAC_MOCK
+from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -31,6 +31,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
) as mock_hvac, patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
side_effect=InvalidMethod,
diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py
index 34a563b7af84..33f0175bdb70 100644
--- a/tests/components/airzone/test_diagnostics.py
+++ b/tests/components/airzone/test_diagnostics.py
@@ -7,14 +7,15 @@ from aioairzone.const import (
API_MAC,
API_SYSTEM_ID,
API_SYSTEMS,
+ API_VERSION,
API_WIFI_RSSI,
AZD_ID,
AZD_MASTER,
AZD_SYSTEM,
AZD_SYSTEMS,
AZD_ZONES,
- AZD_ZONES_NUM,
RAW_HVAC,
+ RAW_VERSION,
RAW_WEBSERVER,
)
@@ -23,7 +24,13 @@ from homeassistant.components.diagnostics import REDACTED
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
-from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK, async_init_integration
+from .util import (
+ CONFIG,
+ HVAC_MOCK,
+ HVAC_VERSION_MOCK,
+ HVAC_WEBSERVER_MOCK,
+ async_init_integration,
+)
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@@ -41,6 +48,7 @@ async def test_config_entry_diagnostics(
"homeassistant.components.airzone.AirzoneLocalApi.raw_data",
return_value={
RAW_HVAC: HVAC_MOCK,
+ RAW_VERSION: HVAC_VERSION_MOCK,
RAW_WEBSERVER: HVAC_WEBSERVER_MOCK,
},
):
@@ -53,6 +61,13 @@ async def test_config_entry_diagnostics(
}.items()
)
+ assert (
+ diag["api_data"][RAW_VERSION].items()
+ >= {
+ API_VERSION: HVAC_VERSION_MOCK[API_VERSION],
+ }.items()
+ )
+
assert (
diag["api_data"][RAW_WEBSERVER].items()
>= {
@@ -77,7 +92,6 @@ async def test_config_entry_diagnostics(
diag["coord_data"][AZD_SYSTEMS]["1"].items()
>= {
AZD_ID: 1,
- AZD_ZONES_NUM: 5,
}.items()
)
diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py
index 274ceb21d513..bb7cb06d1c24 100644
--- a/tests/components/airzone/test_init.py
+++ b/tests/components/airzone/test_init.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK
+from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK
from tests.common import MockConfigEntry
@@ -28,6 +28,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
side_effect=InvalidMethod,
@@ -47,6 +50,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
side_effect=SystemOutOfRange,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
return_value=HVAC_WEBSERVER_MOCK,
diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py
index 545a45508de4..c7c320221236 100644
--- a/tests/components/airzone/test_select.py
+++ b/tests/components/airzone/test_select.py
@@ -25,49 +25,49 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None:
await async_init_integration(hass)
state = hass.states.get("select.despacho_cold_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.despacho_heat_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.despacho_sleep")
- assert state.state == "Off"
+ assert state.state == "off"
state = hass.states.get("select.dorm_1_cold_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.dorm_1_heat_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.dorm_1_sleep")
- assert state.state == "Off"
+ assert state.state == "off"
state = hass.states.get("select.dorm_2_cold_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.dorm_2_heat_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.dorm_2_sleep")
- assert state.state == "Off"
+ assert state.state == "off"
state = hass.states.get("select.dorm_ppal_cold_angle")
- assert state.state == "45º"
+ assert state.state == "45deg"
state = hass.states.get("select.dorm_ppal_heat_angle")
- assert state.state == "50º"
+ assert state.state == "50deg"
state = hass.states.get("select.dorm_ppal_sleep")
assert state.state == "30m"
state = hass.states.get("select.salon_cold_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.salon_heat_angle")
- assert state.state == "90º"
+ assert state.state == "90deg"
state = hass.states.get("select.salon_sleep")
- assert state.state == "Off"
+ assert state.state == "off"
async def test_airzone_select_sleep(hass: HomeAssistant) -> None:
@@ -140,13 +140,13 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None:
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.dorm_1_cold_angle",
- ATTR_OPTION: "50º",
+ ATTR_OPTION: "50deg",
},
blocking=True,
)
state = hass.states.get("select.dorm_1_cold_angle")
- assert state.state == "50º"
+ assert state.state == "50deg"
# Heat Angle
@@ -168,10 +168,10 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None:
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.dorm_1_heat_angle",
- ATTR_OPTION: "45º",
+ ATTR_OPTION: "45deg",
},
blocking=True,
)
state = hass.states.get("select.dorm_1_heat_angle")
- assert state.state == "45º"
+ assert state.state == "45deg"
diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py
index c72c083039e1..1d778761ee18 100644
--- a/tests/components/airzone/test_sensor.py
+++ b/tests/components/airzone/test_sensor.py
@@ -1,8 +1,23 @@
"""The sensor tests for the Airzone platform."""
-from homeassistant.core import HomeAssistant
+from unittest.mock import patch
-from .util import async_init_integration
+from aioairzone.const import API_DATA, API_SYSTEMS
+
+from homeassistant.components.airzone.coordinator import SCAN_INTERVAL
+from homeassistant.const import STATE_UNAVAILABLE
+from homeassistant.core import HomeAssistant
+from homeassistant.util.dt import utcnow
+
+from .util import (
+ HVAC_MOCK,
+ HVAC_SYSTEMS_MOCK,
+ HVAC_VERSION_MOCK,
+ HVAC_WEBSERVER_MOCK,
+ async_init_integration,
+)
+
+from tests.common import async_fire_time_changed
async def test_airzone_create_sensors(
@@ -58,3 +73,36 @@ async def test_airzone_create_sensors(
state = hass.states.get("sensor.dkn_plus_humidity")
assert state is None
+
+
+async def test_airzone_sensors_availability(
+ hass: HomeAssistant, entity_registry_enabled_by_default: None
+) -> None:
+ """Test sensors availability."""
+
+ await async_init_integration(hass)
+
+ HVAC_MOCK_UNAVAILABLE_ZONE = {**HVAC_MOCK}
+ del HVAC_MOCK_UNAVAILABLE_ZONE[API_SYSTEMS][0][API_DATA][1]
+
+ with patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac",
+ return_value=HVAC_MOCK_UNAVAILABLE_ZONE,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
+ return_value=HVAC_SYSTEMS_MOCK,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
+ return_value=HVAC_WEBSERVER_MOCK,
+ ):
+ async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.dorm_ppal_temperature")
+ assert state.state == STATE_UNAVAILABLE
+
+ state = hass.states.get("sensor.dorm_ppal_humidity")
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py
index bbbe00a431b6..4afcaeac2329 100644
--- a/tests/components/airzone/util.py
+++ b/tests/components/airzone/util.py
@@ -93,6 +93,8 @@ HVAC_MOCK = {
API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0,
+ API_SPEED: 0,
+ API_SPEEDS: 3,
},
{
API_SYSTEM_ID: 1,
@@ -119,6 +121,8 @@ HVAC_MOCK = {
API_FLOOR_DEMAND: 1,
API_HEAT_ANGLE: 1,
API_COLD_ANGLE: 2,
+ API_SPEED: 0,
+ API_SPEEDS: 2,
},
{
API_SYSTEM_ID: 1,
@@ -221,6 +225,8 @@ HVAC_MOCK = {
API_HUMIDITY: 62,
API_UNITS: 0,
API_ERRORS: [],
+ API_SPEED: 0,
+ API_SPEEDS: 4,
},
]
},
@@ -300,6 +306,9 @@ async def async_init_integration(
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems",
return_value=HVAC_SYSTEMS_MOCK,
+ ), patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.get_version",
+ return_value=HVAC_VERSION_MOCK,
), patch(
"homeassistant.components.airzone.AirzoneLocalApi.get_webserver",
return_value=HVAC_WEBSERVER_MOCK,
diff --git a/tests/components/airzone_cloud/__init__.py b/tests/components/airzone_cloud/__init__.py
new file mode 100644
index 000000000000..a00eeb760ebf
--- /dev/null
+++ b/tests/components/airzone_cloud/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Airzone Cloud integration."""
diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py
new file mode 100644
index 000000000000..ec031d4bf257
--- /dev/null
+++ b/tests/components/airzone_cloud/test_config_flow.py
@@ -0,0 +1,143 @@
+"""Define tests for the Airzone Cloud config flow."""
+
+from unittest.mock import patch
+
+from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError
+
+from homeassistant import data_entry_flow
+from homeassistant.components.airzone_cloud.const import DOMAIN
+from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
+from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+
+from .util import (
+ CONFIG,
+ GET_INSTALLATION_MOCK,
+ GET_INSTALLATIONS_MOCK,
+ WS_ID,
+ mock_get_device_status,
+ mock_get_webserver,
+)
+
+
+async def test_form(hass: HomeAssistant) -> None:
+ """Test that the form is served with valid input."""
+
+ with patch(
+ "homeassistant.components.airzone_cloud.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry, patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
+ side_effect=mock_get_device_status,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
+ return_value=GET_INSTALLATION_MOCK,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
+ return_value=GET_INSTALLATIONS_MOCK,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
+ side_effect=mock_get_webserver,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ return_value=None,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_USERNAME: CONFIG[CONF_USERNAME],
+ CONF_PASSWORD: CONFIG[CONF_PASSWORD],
+ },
+ )
+
+ assert result["type"] == data_entry_flow.FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ID: CONFIG[CONF_ID],
+ },
+ )
+
+ await hass.async_block_till_done()
+
+ conf_entries = hass.config_entries.async_entries(DOMAIN)
+ entry = conf_entries[0]
+ assert entry.state is ConfigEntryState.LOADED
+
+ assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
+ assert result["title"] == f"House {WS_ID} ({CONFIG[CONF_ID]})"
+ assert result["data"][CONF_ID] == CONFIG[CONF_ID]
+ assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME]
+ assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD]
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_installations_list_error(hass: HomeAssistant) -> None:
+ """Test connection error."""
+
+ with patch(
+ "homeassistant.components.airzone_cloud.async_setup_entry",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
+ side_effect=mock_get_device_status,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
+ side_effect=AirzoneCloudError,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
+ side_effect=mock_get_webserver,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ return_value=None,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_USERNAME: CONFIG[CONF_USERNAME],
+ CONF_PASSWORD: CONFIG[CONF_PASSWORD],
+ },
+ )
+
+ assert result["type"] == data_entry_flow.FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_login_error(hass: HomeAssistant) -> None:
+ """Test login error."""
+
+ with patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ side_effect=LoginError,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={
+ CONF_USERNAME: CONFIG[CONF_USERNAME],
+ CONF_PASSWORD: CONFIG[CONF_PASSWORD],
+ },
+ )
+
+ assert result["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py
new file mode 100644
index 000000000000..40b6c937ed2a
--- /dev/null
+++ b/tests/components/airzone_cloud/test_coordinator.py
@@ -0,0 +1,70 @@
+"""Define tests for the Airzone Cloud coordinator."""
+
+from unittest.mock import patch
+
+from aioairzone_cloud.exceptions import AirzoneCloudError
+
+from homeassistant.components.airzone_cloud.const import DOMAIN
+from homeassistant.components.airzone_cloud.coordinator import SCAN_INTERVAL
+from homeassistant.const import STATE_UNAVAILABLE
+from homeassistant.core import HomeAssistant
+from homeassistant.util.dt import utcnow
+
+from .util import (
+ CONFIG,
+ GET_INSTALLATION_MOCK,
+ GET_INSTALLATIONS_MOCK,
+ mock_get_device_status,
+ mock_get_webserver,
+)
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
+ """Test ClientConnectorError on coordinator update."""
+
+ config_entry = MockConfigEntry(
+ data=CONFIG,
+ domain=DOMAIN,
+ unique_id="airzone_cloud_unique_id",
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
+ side_effect=mock_get_device_status,
+ ) as mock_device_status, patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
+ return_value=GET_INSTALLATION_MOCK,
+ ) as mock_installation, patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
+ return_value=GET_INSTALLATIONS_MOCK,
+ ) as mock_installations, patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
+ side_effect=mock_get_webserver,
+ ) as mock_webserver, patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ return_value=None,
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ mock_device_status.assert_called()
+ mock_installation.assert_awaited_once()
+ mock_installations.assert_called_once()
+ mock_webserver.assert_called()
+
+ mock_device_status.reset_mock()
+ mock_installation.reset_mock()
+ mock_installations.reset_mock()
+ mock_webserver.reset_mock()
+
+ mock_device_status.side_effect = AirzoneCloudError
+ async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ mock_device_status.assert_called()
+
+ state = hass.states.get("sensor.salon_temperature")
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py
new file mode 100644
index 000000000000..730ac27325aa
--- /dev/null
+++ b/tests/components/airzone_cloud/test_diagnostics.py
@@ -0,0 +1,118 @@
+"""The diagnostics tests for the Airzone Cloud platform."""
+
+from unittest.mock import patch
+
+from aioairzone_cloud.const import (
+ API_DEVICE_ID,
+ API_DEVICES,
+ API_GROUPS,
+ API_WS_ID,
+ AZD_AIDOOS,
+ AZD_INSTALLATIONS,
+ AZD_SYSTEMS,
+ AZD_WEBSERVERS,
+ AZD_ZONES,
+ RAW_DEVICES_CONFIG,
+ RAW_DEVICES_STATUS,
+ RAW_INSTALLATIONS,
+ RAW_INSTALLATIONS_LIST,
+ RAW_WEBSERVERS,
+)
+
+from homeassistant.components.airzone_cloud.const import DOMAIN
+from homeassistant.components.diagnostics import REDACTED
+from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+
+from .util import CONFIG, WS_ID, async_init_integration
+
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+RAW_DATA_MOCK = {
+ RAW_DEVICES_CONFIG: {
+ "dev1": {},
+ },
+ RAW_DEVICES_STATUS: {
+ "dev1": {},
+ },
+ RAW_INSTALLATIONS: {
+ CONFIG[CONF_ID]: {
+ API_GROUPS: [
+ {
+ API_DEVICES: [
+ {
+ API_DEVICE_ID: "device1",
+ API_WS_ID: WS_ID,
+ },
+ ],
+ },
+ ],
+ "plugins": {
+ "schedules": {
+ "calendar_ws_ids": [
+ WS_ID,
+ ],
+ },
+ },
+ },
+ },
+ RAW_INSTALLATIONS_LIST: {},
+ RAW_WEBSERVERS: {
+ WS_ID: {},
+ },
+ "test_cov": {
+ "1": None,
+ "2": ["foo", "bar"],
+ "3": [
+ [
+ "foo",
+ "bar",
+ ],
+ ],
+ },
+}
+
+
+async def test_config_entry_diagnostics(
+ hass: HomeAssistant, hass_client: ClientSessionGenerator
+) -> None:
+ """Test config entry diagnostics."""
+ await async_init_integration(hass)
+ assert hass.data[DOMAIN]
+
+ config_entry = hass.config_entries.async_entries(DOMAIN)[0]
+ with patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.raw_data",
+ return_value=RAW_DATA_MOCK,
+ ):
+ diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
+
+ assert list(diag["api_data"]) >= list(RAW_DATA_MOCK)
+ assert "dev1" not in diag["api_data"][RAW_DEVICES_CONFIG]
+ assert "device1" in diag["api_data"][RAW_DEVICES_CONFIG]
+ assert "inst1" not in diag["api_data"][RAW_INSTALLATIONS]
+ assert "installation1" in diag["api_data"][RAW_INSTALLATIONS]
+ assert WS_ID not in diag["api_data"][RAW_WEBSERVERS]
+ assert "webserver1" in diag["api_data"][RAW_WEBSERVERS]
+
+ assert (
+ diag["config_entry"].items()
+ >= {
+ "data": {
+ CONF_ID: "installation1",
+ CONF_PASSWORD: REDACTED,
+ CONF_USERNAME: REDACTED,
+ },
+ "domain": DOMAIN,
+ "unique_id": "installation1",
+ }.items()
+ )
+
+ assert list(diag["coord_data"]) >= [
+ AZD_AIDOOS,
+ AZD_INSTALLATIONS,
+ AZD_SYSTEMS,
+ AZD_WEBSERVERS,
+ AZD_ZONES,
+ ]
diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py
new file mode 100644
index 000000000000..3a6497fdebac
--- /dev/null
+++ b/tests/components/airzone_cloud/test_init.py
@@ -0,0 +1,43 @@
+"""Define tests for the Airzone Cloud init."""
+
+from unittest.mock import patch
+
+from homeassistant.components.airzone_cloud.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+
+from .util import CONFIG
+
+from tests.common import MockConfigEntry
+
+
+async def test_unload_entry(hass: HomeAssistant) -> None:
+ """Test unload."""
+
+ config_entry = MockConfigEntry(
+ data=CONFIG,
+ domain=DOMAIN,
+ unique_id="airzone_cloud_unique_id",
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ return_value=None,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations",
+ return_value=[],
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_installation",
+ return_value=None,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.update",
+ return_value=None,
+ ):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert config_entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py
new file mode 100644
index 000000000000..41807fac78bc
--- /dev/null
+++ b/tests/components/airzone_cloud/test_sensor.py
@@ -0,0 +1,37 @@
+"""The sensor tests for the Airzone Cloud platform."""
+
+from homeassistant.core import HomeAssistant
+
+from .util import async_init_integration
+
+
+async def test_airzone_create_sensors(
+ hass: HomeAssistant, entity_registry_enabled_by_default: None
+) -> None:
+ """Test creation of sensors."""
+
+ await async_init_integration(hass)
+
+ # Aidoos
+ state = hass.states.get("sensor.bron_temperature")
+ assert state.state == "21.0"
+
+ # WebServers
+ state = hass.states.get("sensor.webserver_11_22_33_44_55_66_rssi")
+ assert state.state == "-56"
+
+ state = hass.states.get("sensor.webserver_11_22_33_44_55_67_rssi")
+ assert state.state == "-77"
+
+ # Zones
+ state = hass.states.get("sensor.dormitorio_temperature")
+ assert state.state == "25.0"
+
+ state = hass.states.get("sensor.dormitorio_humidity")
+ assert state.state == "24"
+
+ state = hass.states.get("sensor.salon_temperature")
+ assert state.state == "20.0"
+
+ state = hass.states.get("sensor.salon_humidity")
+ assert state.state == "30"
diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py
new file mode 100644
index 000000000000..4eab870297b5
--- /dev/null
+++ b/tests/components/airzone_cloud/util.py
@@ -0,0 +1,239 @@
+"""Tests for the Airzone integration."""
+
+from typing import Any
+from unittest.mock import patch
+
+from aioairzone_cloud.const import (
+ API_AZ_AIDOO,
+ API_AZ_SYSTEM,
+ API_AZ_ZONE,
+ API_CELSIUS,
+ API_CONFIG,
+ API_CONNECTION_DATE,
+ API_DEVICE_ID,
+ API_DEVICES,
+ API_DISCONNECTION_DATE,
+ API_ERRORS,
+ API_FAH,
+ API_GROUPS,
+ API_HUMIDITY,
+ API_INSTALLATION_ID,
+ API_INSTALLATIONS,
+ API_IS_CONNECTED,
+ API_LOCAL_TEMP,
+ API_META,
+ API_NAME,
+ API_STAT_AP_MAC,
+ API_STAT_CHANNEL,
+ API_STAT_QUALITY,
+ API_STAT_RSSI,
+ API_STAT_SSID,
+ API_STATUS,
+ API_SYSTEM_NUMBER,
+ API_TYPE,
+ API_WARNINGS,
+ API_WS_CONNECTED,
+ API_WS_FW,
+ API_WS_ID,
+ API_WS_IDS,
+ API_WS_TYPE,
+ API_ZONE_NUMBER,
+)
+from aioairzone_cloud.device import Device
+from aioairzone_cloud.webserver import WebServer
+
+from homeassistant.components.airzone_cloud import DOMAIN
+from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+WS_ID = "11:22:33:44:55:66"
+WS_ID_AIDOO = "11:22:33:44:55:67"
+
+CONFIG = {
+ CONF_ID: "inst1",
+ CONF_USERNAME: "user",
+ CONF_PASSWORD: "pass",
+}
+
+GET_INSTALLATION_MOCK = {
+ API_GROUPS: [
+ {
+ API_NAME: "Group",
+ API_DEVICES: [
+ {
+ API_DEVICE_ID: "system1",
+ API_TYPE: API_AZ_SYSTEM,
+ API_META: {
+ API_SYSTEM_NUMBER: 1,
+ },
+ API_WS_ID: WS_ID,
+ },
+ {
+ API_DEVICE_ID: "zone1",
+ API_NAME: "Salon",
+ API_TYPE: API_AZ_ZONE,
+ API_META: {
+ API_SYSTEM_NUMBER: 1,
+ API_ZONE_NUMBER: 1,
+ },
+ API_WS_ID: WS_ID,
+ },
+ {
+ API_DEVICE_ID: "zone2",
+ API_NAME: "Dormitorio",
+ API_TYPE: API_AZ_ZONE,
+ API_META: {
+ API_SYSTEM_NUMBER: 1,
+ API_ZONE_NUMBER: 2,
+ },
+ API_WS_ID: WS_ID,
+ },
+ ],
+ },
+ {
+ API_NAME: "Aidoo Group",
+ API_DEVICES: [
+ {
+ API_DEVICE_ID: "aidoo1",
+ API_NAME: "Bron",
+ API_TYPE: API_AZ_AIDOO,
+ API_WS_ID: WS_ID_AIDOO,
+ },
+ ],
+ },
+ ],
+}
+
+GET_INSTALLATIONS_MOCK = {
+ API_INSTALLATIONS: [
+ {
+ API_INSTALLATION_ID: CONFIG[CONF_ID],
+ API_NAME: "House",
+ API_WS_IDS: [
+ WS_ID,
+ WS_ID_AIDOO,
+ ],
+ },
+ ],
+}
+
+GET_WEBSERVER_MOCK = {
+ API_WS_TYPE: "ws_az",
+ API_CONFIG: {
+ API_WS_FW: "3.44",
+ API_STAT_SSID: "Wifi",
+ API_STAT_CHANNEL: 36,
+ API_STAT_AP_MAC: "00:00:00:00:00:00",
+ },
+ API_STATUS: {
+ API_IS_CONNECTED: True,
+ API_STAT_QUALITY: 4,
+ API_STAT_RSSI: -56,
+ API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z",
+ API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z",
+ },
+}
+
+GET_WEBSERVER_MOCK_AIDOO = {
+ API_WS_TYPE: "ws_aidoo",
+ API_CONFIG: {
+ API_WS_FW: "3.13",
+ API_STAT_SSID: "Wifi",
+ API_STAT_CHANNEL: 1,
+ API_STAT_AP_MAC: "00:00:00:00:00:01",
+ },
+ API_STATUS: {
+ API_IS_CONNECTED: True,
+ API_STAT_QUALITY: 4,
+ API_STAT_RSSI: -77,
+ API_CONNECTION_DATE: "2023-05-24 17:00:52 +0200",
+ API_DISCONNECTION_DATE: "2023-05-24 17:00:25 +0200",
+ },
+}
+
+
+def mock_get_device_status(device: Device) -> dict[str, Any]:
+ """Mock API device status."""
+
+ if device.get_id() == "aidoo1":
+ return {
+ API_ERRORS: [],
+ API_IS_CONNECTED: True,
+ API_WS_CONNECTED: True,
+ API_LOCAL_TEMP: {
+ API_CELSIUS: 21,
+ API_FAH: 70,
+ },
+ API_WARNINGS: [],
+ }
+ if device.get_id() == "system1":
+ return {
+ API_ERRORS: [],
+ API_IS_CONNECTED: True,
+ API_WS_CONNECTED: True,
+ API_WARNINGS: [],
+ }
+ if device.get_id() == "zone2":
+ return {
+ API_HUMIDITY: 24,
+ API_IS_CONNECTED: True,
+ API_WS_CONNECTED: True,
+ API_LOCAL_TEMP: {
+ API_FAH: 77,
+ API_CELSIUS: 25,
+ },
+ API_WARNINGS: [],
+ }
+ return {
+ API_HUMIDITY: 30,
+ API_IS_CONNECTED: True,
+ API_WS_CONNECTED: True,
+ API_LOCAL_TEMP: {
+ API_FAH: 68,
+ API_CELSIUS: 20,
+ },
+ API_WARNINGS: [],
+ }
+
+
+def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]:
+ """Mock API get webserver."""
+
+ if webserver.get_id() == WS_ID_AIDOO:
+ return GET_WEBSERVER_MOCK_AIDOO
+
+ return GET_WEBSERVER_MOCK
+
+
+async def async_init_integration(
+ hass: HomeAssistant,
+) -> None:
+ """Set up the Airzone integration in Home Assistant."""
+
+ config_entry = MockConfigEntry(
+ data=CONFIG,
+ domain=DOMAIN,
+ unique_id=CONFIG[CONF_ID],
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
+ side_effect=mock_get_device_status,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
+ return_value=GET_INSTALLATION_MOCK,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
+ return_value=GET_INSTALLATIONS_MOCK,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
+ side_effect=mock_get_webserver,
+ ), patch(
+ "homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
+ return_value=None,
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py
index 0c6731c92be7..c587d94f3ed7 100644
--- a/tests/components/alarm_control_panel/test_device_action.py
+++ b/tests/components/alarm_control_panel/test_device_action.py
@@ -28,7 +28,11 @@ from tests.common import (
async_get_device_automation_capabilities,
async_get_device_automations,
)
-from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
+
+
+@pytest.fixture(autouse=True, name="stub_blueprint_populate")
+def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
+ """Stub copying the blueprints to the config folder."""
@pytest.mark.parametrize(
diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py
index 7a3ca14e0cc3..b1687a385b39 100644
--- a/tests/components/alarm_control_panel/test_device_condition.py
+++ b/tests/components/alarm_control_panel/test_device_condition.py
@@ -27,7 +27,11 @@ from tests.common import (
async_get_device_automations,
async_mock_service,
)
-from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
+
+
+@pytest.fixture(autouse=True, name="stub_blueprint_populate")
+def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
+ """Stub copying the blueprints to the config folder."""
@pytest.fixture
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index f6eeef952bdd..d81c83702d8a 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -32,7 +32,11 @@ from tests.common import (
async_get_device_automations,
async_mock_service,
)
-from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
+
+
+@pytest.fixture(autouse=True, name="stub_blueprint_populate")
+def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
+ """Stub copying the blueprints to the config folder."""
@pytest.fixture
diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py
index c3c0fdcbd416..03546c0ed226 100644
--- a/tests/components/alexa/test_intent.py
+++ b/tests/components/alexa/test_intent.py
@@ -77,6 +77,12 @@ def alexa_client(event_loop, hass, hass_client):
"text": "You told us your sign is {{ ZodiacSign }}.",
}
},
+ "GetZodiacHoroscopeIDIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "You told us your sign is {{ ZodiacSign_Id }}.",
+ }
+ },
"AMAZON.PlaybackAction