This commit is contained in:
Franck Nijhof 2023-12-06 17:44:26 +01:00 committed by GitHub
commit ea1222bff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2138 changed files with 96438 additions and 25041 deletions

View File

@ -67,9 +67,6 @@ omit =
homeassistant/components/android_ip_webcam/switch.py
homeassistant/components/anel_pwrctrl/switch.py
homeassistant/components/anthemav/media_player.py
homeassistant/components/apcupsd/__init__.py
homeassistant/components/apcupsd/binary_sensor.py
homeassistant/components/apcupsd/sensor.py
homeassistant/components/apple_tv/__init__.py
homeassistant/components/apple_tv/browse_media.py
homeassistant/components/apple_tv/media_player.py
@ -123,6 +120,7 @@ omit =
homeassistant/components/blink/binary_sensor.py
homeassistant/components/blink/camera.py
homeassistant/components/blink/sensor.py
homeassistant/components/blink/switch.py
homeassistant/components/blinksticklight/light.py
homeassistant/components/blockchain/sensor.py
homeassistant/components/bloomsky/*
@ -144,6 +142,7 @@ omit =
homeassistant/components/braviatv/coordinator.py
homeassistant/components/braviatv/media_player.py
homeassistant/components/braviatv/remote.py
homeassistant/components/broadlink/climate.py
homeassistant/components/broadlink/light.py
homeassistant/components/broadlink/remote.py
homeassistant/components/broadlink/switch.py
@ -216,9 +215,6 @@ omit =
homeassistant/components/discogs/sensor.py
homeassistant/components/discord/__init__.py
homeassistant/components/discord/notify.py
homeassistant/components/discovergy/__init__.py
homeassistant/components/discovergy/sensor.py
homeassistant/components/discovergy/coordinator.py
homeassistant/components/dlib_face_detect/image_processing.py
homeassistant/components/dlib_face_identify/image_processing.py
homeassistant/components/dlink/data.py
@ -338,7 +334,6 @@ omit =
homeassistant/components/epson/__init__.py
homeassistant/components/epson/media_player.py
homeassistant/components/epsonworkforce/sensor.py
homeassistant/components/eq3btsmart/climate.py
homeassistant/components/escea/__init__.py
homeassistant/components/escea/climate.py
homeassistant/components/escea/discovery.py
@ -369,7 +364,8 @@ omit =
homeassistant/components/faa_delays/binary_sensor.py
homeassistant/components/faa_delays/coordinator.py
homeassistant/components/familyhub/camera.py
homeassistant/components/fastdotcom/*
homeassistant/components/fastdotcom/sensor.py
homeassistant/components/fastdotcom/__init__.py
homeassistant/components/ffmpeg/camera.py
homeassistant/components/fibaro/__init__.py
homeassistant/components/fibaro/binary_sensor.py
@ -426,9 +422,7 @@ omit =
homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py
homeassistant/components/freebox/camera.py
homeassistant/components/freebox/device_tracker.py
homeassistant/components/freebox/home_base.py
homeassistant/components/freebox/router.py
homeassistant/components/freebox/switch.py
homeassistant/components/fritz/common.py
homeassistant/components/fritz/device_tracker.py
@ -769,9 +763,6 @@ omit =
homeassistant/components/mutesync/binary_sensor.py
homeassistant/components/mvglive/sensor.py
homeassistant/components/mycroft/*
homeassistant/components/myq/__init__.py
homeassistant/components/myq/cover.py
homeassistant/components/myq/light.py
homeassistant/components/mysensors/__init__.py
homeassistant/components/mysensors/climate.py
homeassistant/components/mysensors/cover.py
@ -822,7 +813,6 @@ omit =
homeassistant/components/nfandroidtv/__init__.py
homeassistant/components/nfandroidtv/notify.py
homeassistant/components/nibe_heatpump/__init__.py
homeassistant/components/nibe_heatpump/climate.py
homeassistant/components/nibe_heatpump/binary_sensor.py
homeassistant/components/nibe_heatpump/select.py
homeassistant/components/nibe_heatpump/sensor.py
@ -837,6 +827,7 @@ omit =
homeassistant/components/noaa_tides/sensor.py
homeassistant/components/nobo_hub/__init__.py
homeassistant/components/nobo_hub/climate.py
homeassistant/components/nobo_hub/select.py
homeassistant/components/nobo_hub/sensor.py
homeassistant/components/norway_air/air_quality.py
homeassistant/components/notify_events/notify.py
@ -937,6 +928,9 @@ omit =
homeassistant/components/panasonic_viera/media_player.py
homeassistant/components/pandora/media_player.py
homeassistant/components/pencom/switch.py
homeassistant/components/permobil/__init__.py
homeassistant/components/permobil/coordinator.py
homeassistant/components/permobil/sensor.py
homeassistant/components/philips_js/__init__.py
homeassistant/components/philips_js/light.py
homeassistant/components/philips_js/media_player.py
@ -950,8 +944,6 @@ omit =
homeassistant/components/pilight/light.py
homeassistant/components/pilight/switch.py
homeassistant/components/ping/__init__.py
homeassistant/components/ping/binary_sensor.py
homeassistant/components/ping/device_tracker.py
homeassistant/components/ping/helpers.py
homeassistant/components/pioneer/media_player.py
homeassistant/components/plaato/__init__.py
@ -1069,6 +1061,7 @@ omit =
homeassistant/components/roomba/sensor.py
homeassistant/components/roomba/vacuum.py
homeassistant/components/roon/__init__.py
homeassistant/components/roon/event.py
homeassistant/components/roon/media_browser.py
homeassistant/components/roon/media_player.py
homeassistant/components/roon/server.py
@ -1132,10 +1125,7 @@ omit =
homeassistant/components/sky_hub/*
homeassistant/components/skybeacon/sensor.py
homeassistant/components/skybell/__init__.py
homeassistant/components/skybell/binary_sensor.py
homeassistant/components/skybell/camera.py
homeassistant/components/skybell/coordinator.py
homeassistant/components/skybell/entity.py
homeassistant/components/skybell/light.py
homeassistant/components/skybell/sensor.py
homeassistant/components/skybell/switch.py
@ -1291,9 +1281,11 @@ omit =
homeassistant/components/system_bridge/__init__.py
homeassistant/components/system_bridge/binary_sensor.py
homeassistant/components/system_bridge/coordinator.py
homeassistant/components/system_bridge/entity.py
homeassistant/components/system_bridge/media_player.py
homeassistant/components/system_bridge/notify.py
homeassistant/components/system_bridge/sensor.py
homeassistant/components/system_bridge/update.py
homeassistant/components/systemmonitor/sensor.py
homeassistant/components/tado/__init__.py
homeassistant/components/tado/binary_sensor.py
@ -1431,6 +1423,13 @@ omit =
homeassistant/components/upnp/device.py
homeassistant/components/upnp/sensor.py
homeassistant/components/vasttrafik/sensor.py
homeassistant/components/v2c/__init__.py
homeassistant/components/v2c/binary_sensor.py
homeassistant/components/v2c/coordinator.py
homeassistant/components/v2c/entity.py
homeassistant/components/v2c/number.py
homeassistant/components/v2c/sensor.py
homeassistant/components/v2c/switch.py
homeassistant/components/velbus/__init__.py
homeassistant/components/velbus/binary_sensor.py
homeassistant/components/velbus/button.py
@ -1467,6 +1466,7 @@ omit =
homeassistant/components/vicare/button.py
homeassistant/components/vicare/climate.py
homeassistant/components/vicare/entity.py
homeassistant/components/vicare/number.py
homeassistant/components/vicare/sensor.py
homeassistant/components/vicare/utils.py
homeassistant/components/vicare/water_heater.py

View File

@ -10,6 +10,8 @@
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"ms-python.pylint",
"ms-python.vscode-pylance",
"visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml",
@ -19,14 +21,6 @@
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.blackPath": "/usr/local/bin/black",
"python.linting.pycodestylePath": "/usr/local/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/bin/pydocstyle",
"python.linting.mypyPath": "/usr/local/bin/mypy",
"python.linting.pylintPath": "/usr/local/bin/pylint",
"python.formatting.provider": "black",
"python.testing.pytestArgs": ["--no-cov"],
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
@ -45,7 +39,10 @@
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_merge_named scalar"
]
],
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
}
}
}
}

View File

@ -60,7 +60,7 @@
- [ ] There is no commented out code in this PR.
- [ ] I have followed the [development checklist][dev-checklist]
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] The code has been formatted using Black (`black --fast homeassistant tests`)
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [ ] Tests have been added to verify that the new code works.
If user exposed functionality or configuration variables are added/changed:

View File

@ -330,7 +330,7 @@ jobs:
uses: actions/checkout@v4.1.1
- name: Install Cosign
uses: sigstore/cosign-installer@v3.1.2
uses: sigstore/cosign-installer@v3.2.0
with:
cosign-release: "v2.0.2"

View File

@ -35,9 +35,8 @@ on:
env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 5
BLACK_CACHE_VERSION: 1
HA_SHORT_VERSION: "2023.11"
MYPY_CACHE_VERSION: 6
HA_SHORT_VERSION: "2023.12"
DEFAULT_PYTHON: "3.11"
ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
# 10.3 is the oldest supported version
@ -58,7 +57,6 @@ env:
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
PIP_CACHE: /tmp/pip-cache
BLACK_CACHE: /tmp/black-cache
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@ -261,8 +259,8 @@ jobs:
. venv/bin/activate
pre-commit install-hooks
lint-black:
name: Check black
lint-ruff-format:
name: Check ruff-format
runs-on: ubuntu-22.04
needs:
- info
@ -276,13 +274,6 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Generate partial black restore key
id: generate-black-key
run: |
black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3)
echo "version=$black_version" >> $GITHUB_OUTPUT
echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v3.3.2
@ -301,33 +292,12 @@ jobs:
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Restore black cache
uses: actions/cache@v3.3.2
with:
path: ${{ env.BLACK_CACHE }}
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-black-key.outputs.key }}
restore-keys: |
${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{
env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}-
- name: Run black (fully)
if: needs.info.outputs.test_full_suite == 'true'
env:
BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }}
- name: Run ruff-format
run: |
. venv/bin/activate
pre-commit run --hook-stage manual black --all-files --show-diff-on-failure
- name: Run black (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
env:
BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }}
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure
RUFF_OUTPUT_FORMAT: github
lint-ruff:
name: Check ruff
@ -362,22 +332,12 @@ jobs:
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Register ruff problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/ruff.json"
- name: Run ruff (fully)
if: needs.info.outputs.test_full_suite == 'true'
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
- name: Run ruff (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual ruff --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: ubuntu-22.04
@ -787,7 +747,7 @@ jobs:
cov_params+=(--cov-report=xml)
fi
python3 -X dev -m pytest \
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \
--durations=10 \
@ -824,7 +784,7 @@ jobs:
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \
-n auto \
@ -945,7 +905,7 @@ jobs:
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
python3 -b -X dev -m pytest \
-qq \
--timeout=20 \
-n 1 \
@ -1069,7 +1029,7 @@ jobs:
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \
-n 1 \

View File

@ -29,11 +29,11 @@ jobs:
uses: actions/checkout@v4.1.1
- name: Initialize CodeQL
uses: github/codeql-action/init@v2.22.4
uses: github/codeql-action/init@v2.22.8
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2.22.4
uses: github/codeql-action/analyze@v2.22.8
with:
category: "/language:python"

View File

@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4.0.1
- uses: dessant/lock-threads@v5.0.1
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"

View File

@ -1,30 +0,0 @@
{
"problemMatcher": [
{
"owner": "ruff-error",
"severity": "error",
"pattern": [
{
"regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
},
{
"owner": "ruff-warning",
"severity": "warning",
"pattern": [
{
"regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
}
]
}

View File

@ -1,16 +1,11 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.1
rev: v0.1.6
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 23.10.0
hooks:
- id: black
args:
- --quiet
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.py$
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
@ -39,7 +34,7 @@ repos:
hooks:
- id: yamllint
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
rev: v3.0.3
hooks:
- id: prettier
- repo: https://github.com/cdce8p/python-typing-update

View File

@ -5,3 +5,4 @@ homeassistant/components/*/translations/*.json
homeassistant/generated/*
tests/components/lidarr/fixtures/initialize.js
tests/components/lidarr/fixtures/initialize-wrong.js
tests/fixtures/core/config/yaml_errors/

View File

@ -180,6 +180,7 @@ homeassistant.components.image_upload.*
homeassistant.components.imap.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
homeassistant.components.integration.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
@ -201,6 +202,7 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linear_garage_door.*
homeassistant.components.litejet.*
homeassistant.components.litterrobot.*
homeassistant.components.local_ip.*

View File

@ -1,3 +1,7 @@
{
"recommendations": ["esbenp.prettier-vscode", "ms-python.python"]
"recommendations": [
"charliermarsh.ruff",
"esbenp.prettier-vscode",
"ms-python.python"
]
}

8
.vscode/launch.json vendored
View File

@ -22,6 +22,14 @@
"args": ["--debug", "-c", "config", "--skip-pip"],
"preLaunchTask": "Compile English translations"
},
{
"name": "Home Assistant: Changed tests",
"type": "python",
"request": "launch",
"module": "pytest",
"justMyCode": false,
"args": ["--timeout=10", "--picked"],
},
{
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/

View File

@ -1,6 +1,5 @@
{
// Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json
"python.formatting.provider": "black",
// Added --no-cov to work around TypeError: message must be set
// https://github.com/microsoft/vscode-python/issues/14067
"python.testing.pytestArgs": ["--no-cov"],

View File

@ -1,5 +1,6 @@
ignore: |
azure-*.yml
tests/fixtures/core/config/yaml_errors/
rules:
braces:
level: error

View File

@ -151,8 +151,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @riokuu
/tests/components/blebox/ @bbx-a @riokuu
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/bluemaestro/ @bdraco
/tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core
@ -170,8 +170,8 @@ build.json @home-assistant/supervisor
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/braviatv/ @bieniu @Drafteed
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/homeassistant/components/brother/ @bieniu
/tests/components/brother/ @bieniu
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
@ -259,6 +259,8 @@ build.json @home-assistant/supervisor
/tests/components/denonavr/ @ol-iver @starkillerOG
/homeassistant/components/derivative/ @afaucogney
/tests/components/derivative/ @afaucogney
/homeassistant/components/devialet/ @fwestenberg
/tests/components/devialet/ @fwestenberg
/homeassistant/components/device_automation/ @home-assistant/core
/tests/components/device_automation/ @home-assistant/core
/homeassistant/components/device_tracker/ @home-assistant/core
@ -307,12 +309,12 @@ build.json @home-assistant/supervisor
/tests/components/eafm/ @Jc2k
/homeassistant/components/easyenergy/ @klaasnicolaas
/tests/components/easyenergy/ @klaasnicolaas
/homeassistant/components/ecobee/ @marthoc @marcolivierarsenault
/tests/components/ecobee/ @marthoc @marcolivierarsenault
/homeassistant/components/ecobee/ @marcolivierarsenault
/tests/components/ecobee/ @marcolivierarsenault
/homeassistant/components/ecoforest/ @pjanuario
/tests/components/ecoforest/ @pjanuario
/homeassistant/components/econet/ @vangorra @w1ll1am23
/tests/components/econet/ @vangorra @w1ll1am23
/homeassistant/components/econet/ @w1ll1am23
/tests/components/econet/ @w1ll1am23
/homeassistant/components/ecovacs/ @OverloadUT @mib1185
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
@ -345,17 +347,15 @@ build.json @home-assistant/supervisor
/homeassistant/components/enigma2/ @fbradyirl
/homeassistant/components/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek
/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/envisalink/ @ufodone
/homeassistant/components/ephember/ @ttroy50
/homeassistant/components/epson/ @pszafer
/tests/components/epson/ @pszafer
/homeassistant/components/epsonworkforce/ @ThaStealth
/homeassistant/components/eq3btsmart/ @rytilahti
/homeassistant/components/escea/ @lazdavila
/tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco
@ -373,7 +373,8 @@ build.json @home-assistant/supervisor
/tests/components/faa_delays/ @ntilley905
/homeassistant/components/fan/ @home-assistant/core
/tests/components/fan/ @home-assistant/core
/homeassistant/components/fastdotcom/ @rohankapoorcom
/homeassistant/components/fastdotcom/ @rohankapoorcom @erwindouna
/tests/components/fastdotcom/ @rohankapoorcom @erwindouna
/homeassistant/components/fibaro/ @rappenze
/tests/components/fibaro/ @rappenze
/homeassistant/components/file/ @fabaff
@ -490,8 +491,6 @@ build.json @home-assistant/supervisor
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
/tests/components/group/ @home-assistant/core
/homeassistant/components/growatt_server/ @muppet3000
/tests/components/growatt_server/ @muppet3000
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
/homeassistant/components/habitica/ @ASMfreaK @leikoilja
@ -699,6 +698,8 @@ build.json @home-assistant/supervisor
/tests/components/life360/ @pnbruckner
/homeassistant/components/light/ @home-assistant/core
/tests/components/light/ @home-assistant/core
/homeassistant/components/linear_garage_door/ @IceBotYT
/tests/components/linear_garage_door/ @IceBotYT
/homeassistant/components/linux_battery/ @fabaff
/homeassistant/components/litejet/ @joncar
/tests/components/litejet/ @joncar
@ -811,8 +812,6 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myq/ @ehendrix23 @Lash-L
/tests/components/myq/ @ehendrix23 @Lash-L
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
@ -929,6 +928,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/oru/ @bvlaicu
/homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/homeassistant/components/ovo_energy/ @timmo001
@ -943,6 +944,8 @@ build.json @home-assistant/supervisor
/tests/components/peco/ @IceBotYT
/homeassistant/components/pegel_online/ @mib1185
/tests/components/pegel_online/ @mib1185
/homeassistant/components/permobil/ @IsakNyberg
/tests/components/permobil/ @IsakNyberg
/homeassistant/components/persistent_notification/ @home-assistant/core
/tests/components/persistent_notification/ @home-assistant/core
/homeassistant/components/philips_js/ @elupus
@ -979,6 +982,8 @@ build.json @home-assistant/supervisor
/tests/components/prometheus/ @knyar
/homeassistant/components/prosegur/ @dgomes
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/homeassistant/components/prusalink/ @balloob
/tests/components/prusalink/ @balloob
@ -1052,7 +1057,7 @@ build.json @home-assistant/supervisor
/tests/components/reolink/ @starkillerOG
/homeassistant/components/repairs/ @home-assistant/core
/tests/components/repairs/ @home-assistant/core
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
/homeassistant/components/repetier/ @ShadowBr0ther
/homeassistant/components/rflink/ @javicalle
/tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
@ -1061,6 +1066,8 @@ build.json @home-assistant/supervisor
/tests/components/rhasspy/ @balloob @synesthesiam
/homeassistant/components/ridwell/ @bachya
/tests/components/ridwell/ @bachya
/homeassistant/components/ring/ @sdb9696
/tests/components/ring/ @sdb9696
/homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
@ -1231,8 +1238,8 @@ build.json @home-assistant/supervisor
/tests/components/stookwijzer/ @fwestenberg
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
/tests/components/stream/ @hunterjm @uvjustin @allenporter
/homeassistant/components/stt/ @home-assistant/core @pvizeli
/tests/components/stt/ @home-assistant/core @pvizeli
/homeassistant/components/stt/ @home-assistant/core
/tests/components/stt/ @home-assistant/core
/homeassistant/components/subaru/ @G-Two
/tests/components/subaru/ @G-Two
/homeassistant/components/suez_water/ @ooii
@ -1317,8 +1324,8 @@ build.json @home-assistant/supervisor
/tests/components/tomorrowio/ @raman325 @lymanepp
/homeassistant/components/totalconnect/ @austinmroczek
/tests/components/totalconnect/ @austinmroczek
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey
/tests/components/tplink/ @rytilahti @thegardenmonkey
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco
/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco
/homeassistant/components/tplink_omada/ @MarkGodwin
/tests/components/tplink_omada/ @MarkGodwin
/homeassistant/components/traccar/ @ludeeus
@ -1339,8 +1346,8 @@ build.json @home-assistant/supervisor
/tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/trend/ @jpbede
/tests/components/trend/ @jpbede
/homeassistant/components/tts/ @home-assistant/core @pvizeli
/tests/components/tts/ @home-assistant/core @pvizeli
/homeassistant/components/tts/ @home-assistant/core
/tests/components/tts/ @home-assistant/core
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
/tests/components/tuya/ @Tuya @zlinoliver @frenck
/homeassistant/components/twentemilieu/ @frenck
@ -1375,6 +1382,8 @@ build.json @home-assistant/supervisor
/tests/components/usgs_earthquakes_feed/ @exxamalte
/homeassistant/components/utility_meter/ @dgomes
/tests/components/utility_meter/ @dgomes
/homeassistant/components/v2c/ @dgomes
/tests/components/v2c/ @dgomes
/homeassistant/components/vacuum/ @home-assistant/core
/tests/components/vacuum/ @home-assistant/core
/homeassistant/components/vallox/ @andre-richter @slovdahl @viiru-
@ -1384,13 +1393,13 @@ build.json @home-assistant/supervisor
/homeassistant/components/velux/ @Julius2342
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/verisure/ @frenck
/tests/components/verisure/ @frenck
/homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel
@ -1501,8 +1510,8 @@ build.json @home-assistant/supervisor
/tests/components/zerproc/ @emlove
/homeassistant/components/zeversolar/ @kvanzuijlen
/tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly
/tests/components/zha/ @dmulcahey @adminiuga @puddly
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core

View File

@ -1,3 +1,6 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${BUILD_FROM}

View File

@ -5,8 +5,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN \
pipx uninstall black \
&& pipx uninstall pydocstyle \
pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint

View File

@ -280,7 +280,8 @@ class AuthManager:
credentials=credentials,
name=info.name,
is_active=info.is_active,
group_ids=[GROUP_ID_ADMIN],
group_ids=[GROUP_ID_ADMIN if info.group is None else info.group],
local_only=info.local_only,
)
self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id})

View File

@ -134,3 +134,5 @@ class UserMeta(NamedTuple):
name: str | None
is_active: bool
group: str | None = None
local_only: bool | None = None

View File

@ -5,9 +5,7 @@ from collections.abc import Mapping
ValueType = (
# Example: entities.all = { read: true, control: true }
Mapping[str, bool]
| bool
| None
Mapping[str, bool] | bool | None
)
# Example: entities.domains = { light: … }

View File

@ -44,7 +44,11 @@ class CommandLineAuthProvider(AuthProvider):
DEFAULT_TITLE = "Command Line Authentication"
# which keys to accept from a program's stdout
ALLOWED_META_KEYS = ("name",)
ALLOWED_META_KEYS = (
"name",
"group",
"local_only",
)
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Extend parent's __init__.
@ -118,10 +122,15 @@ class CommandLineAuthProvider(AuthProvider):
) -> UserMeta:
"""Return extra user metadata for credentials.
Currently, only name is supported.
Currently, supports name, group and local_only.
"""
meta = self._user_meta.get(credentials.data["username"], {})
return UserMeta(name=meta.get("name"), is_active=True)
return UserMeta(
name=meta.get("name"),
is_active=True,
group=meta.get("group"),
local_only=meta.get("local_only") == "true",
)
class CommandLineLoginFlow(LoginFlow):

View File

@ -10,10 +10,11 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.core import async_get_hass, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from ..models import Credentials, UserMeta
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
@ -21,10 +22,28 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
AUTH_PROVIDER_TYPE = "legacy_api_password"
CONF_API_PASSWORD = "api_password"
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
_CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
{vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA
)
def _create_repair_and_validate(config: dict[str, Any]) -> dict[str, Any]:
async_create_issue(
async_get_hass(),
"auth",
"deprecated_legacy_api_password",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_legacy_api_password",
)
return _CONFIG_SCHEMA(config) # type: ignore[no-any-return]
CONFIG_SCHEMA = _create_repair_and_validate
LEGACY_USER_NAME = "Legacy API password user"

View File

@ -22,6 +22,7 @@ from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import is_cloud_connection
from .. import InvalidAuthError
from ..models import Credentials, RefreshToken, UserMeta
@ -192,11 +193,8 @@ class TrustedNetworksAuthProvider(AuthProvider):
if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies):
raise InvalidAuthError("Can't allow access from a proxy server")
if "cloud" in self.hass.config.components:
from hass_nabucasa import remote # pylint: disable=import-outside-toplevel
if remote.is_cloud_request.get():
raise InvalidAuthError("Can't allow access from Home Assistant Cloud")
if is_cloud_connection(self.hass):
raise InvalidAuthError("Can't allow access from Home Assistant Cloud")
@callback
def async_validate_refresh_token(

View File

@ -41,6 +41,7 @@ from .setup import (
DATA_SETUP,
DATA_SETUP_STARTED,
DATA_SETUP_TIME,
async_notify_setup_error,
async_set_domains_to_be_loaded,
async_setup_component,
)
@ -292,7 +293,8 @@ async def async_from_config_dict(
try:
await conf_util.async_process_ha_core_config(hass, core_config)
except vol.Invalid as config_err:
conf_util.async_log_exception(config_err, "homeassistant", core_config, hass)
conf_util.async_log_schema_error(config_err, core.DOMAIN, core_config, hass)
async_notify_setup_error(hass, core.DOMAIN)
return None
except HomeAssistantError:
_LOGGER.error(
@ -398,7 +400,7 @@ def async_enable_logging(
logging.getLogger("httpx").setLevel(logging.WARNING)
sys.excepthook = lambda *args: logging.getLogger(None).exception(
"Uncaught exception", exc_info=args # type: ignore[arg-type]
"Uncaught exception", exc_info=args
)
threading.excepthook = lambda args: logging.getLogger(None).exception(
"Uncaught thread exception",

View File

@ -1,5 +1,5 @@
{
"domain": "eq3",
"name": "eQ-3",
"integrations": ["eq3btsmart", "maxcube"]
"integrations": ["maxcube"]
}

View File

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

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling",
"loggers": ["adax", "adax_local"],
"requirements": ["adax==0.3.0", "Adax-local==0.1.5"]
"requirements": ["adax==0.4.0", "Adax-local==0.1.5"]
}

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.6.2"]
"requirements": ["adguardhome==0.6.3"]
}

View File

@ -22,20 +22,13 @@ SCAN_INTERVAL = timedelta(seconds=300)
PARALLEL_UPDATES = 4
@dataclass
class AdGuardHomeEntityDescriptionMixin:
"""Mixin for required keys."""
@dataclass(kw_only=True)
class AdGuardHomeEntityDescription(SensorEntityDescription):
"""Describes AdGuard Home sensor entity."""
value_fn: Callable[[AdGuardHome], Coroutine[Any, Any, int | float]]
@dataclass
class AdGuardHomeEntityDescription(
SensorEntityDescription, AdGuardHomeEntityDescriptionMixin
):
"""Describes AdGuard Home sensor entity."""
SENSORS: tuple[AdGuardHomeEntityDescription, ...] = (
AdGuardHomeEntityDescription(
key="dns_queries",

View File

@ -10,6 +10,9 @@
"username": "[%key:common::config_flow::data::username%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"host": "The hostname or IP address of the device running your AdGuard Home."
}
},
"hassio_confirm": {

View File

@ -21,22 +21,15 @@ SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 1
@dataclass
class AdGuardHomeSwitchEntityDescriptionMixin:
"""Mixin for required keys."""
@dataclass(kw_only=True)
class AdGuardHomeSwitchEntityDescription(SwitchEntityDescription):
"""Describes AdGuard Home switch entity."""
is_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, bool]]]
turn_on_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]]
turn_off_fn: Callable[[AdGuardHome], Callable[[], Coroutine[Any, Any, None]]]
@dataclass
class AdGuardHomeSwitchEntityDescription(
SwitchEntityDescription, AdGuardHomeSwitchEntityDescriptionMixin
):
"""Describes AdGuard Home switch entity."""
SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
AdGuardHomeSwitchEntityDescription(
key="protection",

View File

@ -122,6 +122,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
if self._ac.get(ADVANTAGE_AIR_AUTOFAN):
self._attr_fan_modes += [FAN_AUTO]
@property
def current_temperature(self) -> float | None:
"""Return the selected zones current temperature."""
if self._myzone:
return self._myzone["measuredTemp"]
return None
@property
def target_temperature(self) -> float | None:
"""Return the current target temperature."""

View File

@ -1,9 +1,8 @@
"""The AEMET OpenData component."""
import asyncio
import logging
from aemet_opendata.exceptions import TownNotFound
from aemet_opendata.exceptions import AemetError, TownNotFound
from aemet_opendata.interface import AEMET, ConnectionOptions
from homeassistant.config_entries import ConfigEntry
@ -39,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except TownNotFound as err:
_LOGGER.error(err)
return False
except asyncio.TimeoutError as err:
raise ConfigEntryNotReady("AEMET OpenData API timed out") from err
except AemetError as err:
raise ConfigEntryNotReady(err) from err
weather_coordinator = WeatherUpdateCoordinator(hass, aemet)
await weather_coordinator.async_config_entry_first_refresh()

View File

@ -12,6 +12,18 @@ from aemet_opendata.const import (
AOD_COND_RAINY,
AOD_COND_SNOWY,
AOD_COND_SUNNY,
AOD_CONDITION,
AOD_FORECAST_DAILY,
AOD_FORECAST_HOURLY,
AOD_PRECIPITATION,
AOD_PRECIPITATION_PROBABILITY,
AOD_TEMP,
AOD_TEMP_MAX,
AOD_TEMP_MIN,
AOD_TIMESTAMP,
AOD_WIND_DIRECTION,
AOD_WIND_SPEED,
AOD_WIND_SPEED_MAX,
)
from homeassistant.components.weather import (
@ -25,6 +37,15 @@ from homeassistant.components.weather import (
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SUNNY,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
)
from homeassistant.const import Platform
@ -122,3 +143,30 @@ FORECAST_MODE_ATTR_API = {
FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY,
FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY,
}
FORECAST_MAP = {
AOD_FORECAST_DAILY: {
AOD_CONDITION: ATTR_FORECAST_CONDITION,
AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP,
AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW,
AOD_TIMESTAMP: ATTR_FORECAST_TIME,
AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING,
AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
AOD_FORECAST_HOURLY: {
AOD_CONDITION: ATTR_FORECAST_CONDITION,
AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION,
AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP,
AOD_TIMESTAMP: ATTR_FORECAST_TIME,
AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING,
AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
}
WEATHER_FORECAST_MODES = {
AOD_FORECAST_DAILY: "daily",
AOD_FORECAST_HOURLY: "hourly",
}

View File

@ -0,0 +1,23 @@
"""Entity classes for the AEMET OpenData integration."""
from __future__ import annotations
from typing import Any
from aemet_opendata.helpers import dict_nested_value
from homeassistant.components.weather import Forecast
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .weather_update_coordinator import WeatherUpdateCoordinator
class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]):
"""Define an AEMET entity."""
def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]:
"""Return AEMET entity forecast by mode."""
return self.coordinator.data["forecast"][forecast_mode]
def get_aemet_value(self, keys: list[str]) -> Any:
"""Return AEMET entity value by keys."""
return dict_nested_value(self.coordinator.data["lib"], keys)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.4.5"]
"requirements": ["AEMET-OpenData==0.4.6"]
}

View File

@ -1,16 +1,19 @@
"""Support for the AEMET OpenData service."""
from typing import cast
from aemet_opendata.const import (
AOD_CONDITION,
AOD_FORECAST_DAILY,
AOD_FORECAST_HOURLY,
AOD_HUMIDITY,
AOD_PRESSURE,
AOD_TEMP,
AOD_WEATHER,
AOD_WIND_DIRECTION,
AOD_WIND_SPEED,
AOD_WIND_SPEED_MAX,
)
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
DOMAIN as WEATHER_DOMAIN,
Forecast,
SingleCoordinatorWeatherEntity,
@ -28,55 +31,16 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
ATTR_API_CONDITION,
ATTR_API_FORECAST_CONDITION,
ATTR_API_FORECAST_PRECIPITATION,
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_API_FORECAST_TEMP,
ATTR_API_FORECAST_TEMP_LOW,
ATTR_API_FORECAST_TIME,
ATTR_API_FORECAST_WIND_BEARING,
ATTR_API_FORECAST_WIND_MAX_SPEED,
ATTR_API_FORECAST_WIND_SPEED,
ATTR_API_HUMIDITY,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_MAX_SPEED,
ATTR_API_WIND_SPEED,
ATTRIBUTION,
CONDITIONS_MAP,
DOMAIN,
ENTRY_NAME,
ENTRY_WEATHER_COORDINATOR,
FORECAST_MODE_ATTR_API,
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
FORECAST_MODES,
WEATHER_FORECAST_MODES,
)
from .entity import AemetEntity
from .weather_update_coordinator import WeatherUpdateCoordinator
FORECAST_MAP = {
FORECAST_MODE_DAILY: {
ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION,
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP,
ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME,
ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING,
ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
FORECAST_MODE_HOURLY: {
ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION,
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP,
ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME,
ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING,
ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
}
async def async_setup_entry(
hass: HomeAssistant,
@ -95,11 +59,11 @@ async def async_setup_entry(
if entity_registry.async_get_entity_id(
WEATHER_DOMAIN,
DOMAIN,
f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}",
f"{config_entry.unique_id} {WEATHER_FORECAST_MODES[AOD_FORECAST_HOURLY]}",
):
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
unique_id = f"{config_entry.unique_id} {mode}"
for mode, mode_id in WEATHER_FORECAST_MODES.items():
name = f"{domain_data[ENTRY_NAME]} {mode_id}"
unique_id = f"{config_entry.unique_id} {mode_id}"
entities.append(AemetWeather(name, unique_id, weather_coordinator, mode))
else:
entities.append(
@ -107,15 +71,18 @@ async def async_setup_entry(
domain_data[ENTRY_NAME],
config_entry.unique_id,
weather_coordinator,
FORECAST_MODE_DAILY,
AOD_FORECAST_DAILY,
)
)
async_add_entities(entities, False)
class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Implementation of an AEMET OpenData sensor."""
class AemetWeather(
AemetEntity,
SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator],
):
"""Implementation of an AEMET OpenData weather."""
_attr_attribution = ATTRIBUTION
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
@ -137,7 +104,7 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
super().__init__(coordinator)
self._forecast_mode = forecast_mode
self._attr_entity_registry_enabled_default = (
self._forecast_mode == FORECAST_MODE_DAILY
self._forecast_mode == AOD_FORECAST_DAILY
)
self._attr_name = name
self._attr_unique_id = unique_id
@ -145,61 +112,50 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
@property
def condition(self):
"""Return the current condition."""
return self.coordinator.data[ATTR_API_CONDITION]
def _forecast(self, forecast_mode: str) -> list[Forecast]:
"""Return the forecast array."""
forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]]
forecast_map = FORECAST_MAP[forecast_mode]
return cast(
list[Forecast],
[
{ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()}
for forecast in forecasts
],
)
cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION])
return CONDITIONS_MAP.get(cond)
@property
def forecast(self) -> list[Forecast]:
"""Return the forecast array."""
return self._forecast(self._forecast_mode)
return self.get_aemet_forecast(self._forecast_mode)
@callback
def _async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast(FORECAST_MODE_DAILY)
return self.get_aemet_forecast(AOD_FORECAST_DAILY)
@callback
def _async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units."""
return self._forecast(FORECAST_MODE_HOURLY)
return self.get_aemet_forecast(AOD_FORECAST_HOURLY)
@property
def humidity(self):
"""Return the humidity."""
return self.coordinator.data[ATTR_API_HUMIDITY]
return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY])
@property
def native_pressure(self):
"""Return the pressure."""
return self.coordinator.data[ATTR_API_PRESSURE]
return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE])
@property
def native_temperature(self):
"""Return the temperature."""
return self.coordinator.data[ATTR_API_TEMPERATURE]
return self.get_aemet_value([AOD_WEATHER, AOD_TEMP])
@property
def wind_bearing(self):
"""Return the wind bearing."""
return self.coordinator.data[ATTR_API_WIND_BEARING]
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION])
@property
def native_wind_gust_speed(self):
"""Return the wind gust speed in native units."""
return self.coordinator.data[ATTR_API_WIND_MAX_SPEED]
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX])
@property
def native_wind_speed(self):
"""Return the wind speed."""
return self.coordinator.data[ATTR_API_WIND_SPEED]
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED])

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any, Final
from typing import Any, Final, cast
from aemet_opendata.const import (
AEMET_ATTR_DATE,
@ -31,17 +31,24 @@ from aemet_opendata.const import (
AEMET_ATTR_TEMPERATURE,
AEMET_ATTR_WIND,
AEMET_ATTR_WIND_GUST,
AOD_CONDITION,
AOD_FORECAST,
AOD_FORECAST_DAILY,
AOD_FORECAST_HOURLY,
AOD_TOWN,
ATTR_DATA,
)
from aemet_opendata.exceptions import AemetError
from aemet_opendata.forecast import ForecastValue
from aemet_opendata.helpers import (
dict_nested_value,
get_forecast_day_value,
get_forecast_hour_value,
get_forecast_interval_value,
)
from aemet_opendata.interface import AEMET
from homeassistant.components.weather import Forecast
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@ -79,6 +86,7 @@ from .const import (
ATTR_API_WIND_SPEED,
CONDITIONS_MAP,
DOMAIN,
FORECAST_MAP,
)
_LOGGER = logging.getLogger(__name__)
@ -239,6 +247,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
weather_response, now
)
data = self.aemet.data()
forecasts: list[dict[str, Forecast]] = {
AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY),
AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY),
}
return {
ATTR_API_CONDITION: condition,
ATTR_API_FORECAST_DAILY: forecast_daily,
@ -261,8 +275,29 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
ATTR_API_WIND_BEARING: wind_bearing,
ATTR_API_WIND_MAX_SPEED: wind_max_speed,
ATTR_API_WIND_SPEED: wind_speed,
"forecast": forecasts,
"lib": data,
}
def aemet_forecast(
self,
data: dict[str, Any],
forecast_mode: str,
) -> list[Forecast]:
"""Return the forecast array."""
forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST])
forecast_map = FORECAST_MAP[forecast_mode]
forecast_list: list[dict[str, Any]] = []
for forecast in forecasts:
cur_forecast: dict[str, Any] = {}
for api_key, ha_key in forecast_map.items():
value = forecast[api_key]
if api_key == AOD_CONDITION:
value = CONDITIONS_MAP.get(value)
cur_forecast[ha_key] = value
forecast_list += [cur_forecast]
return cast(list[Forecast], forecast_list)
def _get_daily_forecast_from_weather_response(self, weather_response, now):
if weather_response.daily:
parse = False

View File

@ -6,6 +6,9 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The IP address of the Agent DVR server."
}
}
},

View File

@ -11,6 +11,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
@ -50,6 +51,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Clean up unused device entries with no entities
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
for dev in device_entries:
dev_entities = er.async_entries_for_device(
entity_registry, dev.id, include_disabled_entities=True
)
if not dev_entities:
device_registry.async_remove_device(dev.id)
return True

View File

@ -148,13 +148,14 @@ class AirNowSensor(CoordinatorEntity[AirNowDataUpdateCoordinator], SensorEntity)
) -> None:
"""Initialize."""
super().__init__(coordinator)
_device_id = f"{coordinator.latitude}-{coordinator.longitude}"
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}"
)
self._attr_unique_id = f"{_device_id}-{description.key.lower()}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._attr_unique_id)},
identifiers={(DOMAIN, _device_id)},
manufacturer=DEFAULT_NAME,
name=DEFAULT_NAME,
)

View File

@ -3,7 +3,6 @@ from typing import Final
DOMAIN: Final = "airq"
MANUFACTURER: Final = "CorantGmbH"
TARGET_ROUTE: Final = "average"
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
UPDATE_INTERVAL: float = 10.0

View File

@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL
from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator):
hw_version=info["hw_version"],
)
)
data = await self.airq.get(TARGET_ROUTE)
return self.airq.drop_uncertainties_from_data(data)
return await self.airq.get_latest_data()

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.2.4"]
"requirements": ["aioairq==0.3.1"]
}

View File

@ -12,6 +12,9 @@
"title": "Set up your AirTouch 4 connection details.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your AirTouch controller."
}
}
}

View File

@ -12,6 +12,9 @@
"data": {
"ip_address": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"ip_address": "The hostname or IP address of your AirVisual Pro device."
}
}
},

View File

@ -9,7 +9,6 @@ from aioairzone.const import (
AZD_BATTERY_LOW,
AZD_ERRORS,
AZD_FLOOR_DEMAND,
AZD_NAME,
AZD_PROBLEMS,
AZD_SYSTEMS,
AZD_ZONES,
@ -45,7 +44,6 @@ SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ..
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
key=AZD_PROBLEMS,
name="Problem",
),
)
@ -53,17 +51,16 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
key=AZD_AIR_DEMAND,
name="Air Demand",
translation_key="air_demand",
),
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.BATTERY,
key=AZD_BATTERY_LOW,
name="Battery Low",
),
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
key=AZD_FLOOR_DEMAND,
name="Floor Demand",
translation_key="floor_demand",
),
AirzoneBinarySensorEntityDescription(
attributes={
@ -72,7 +69,6 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
key=AZD_PROBLEMS,
name="Problem",
),
)
@ -149,7 +145,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor):
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, system_data)
self._attr_name = f"System {system_id} {description.name}"
self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()
@ -169,7 +164,6 @@ class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor):
"""Initialize."""
super().__init__(coordinator, entry, system_zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]} {description.name}"
self._attr_unique_id = (
f"{self._attr_unique_id}_{system_zone_id}_{description.key}"
)

View File

@ -19,7 +19,6 @@ from aioairzone.const import (
AZD_MASTER,
AZD_MODE,
AZD_MODES,
AZD_NAME,
AZD_ON,
AZD_SPEED,
AZD_SPEEDS,
@ -32,6 +31,7 @@ from aioairzone.const import (
)
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
@ -114,6 +114,7 @@ async def async_setup_entry(
class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
"""Define an Airzone sensor."""
_attr_name = None
_speeds: dict[int, str] = {}
_speeds_reverse: dict[str, int] = {}
@ -127,7 +128,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
"""Initialize Airzone climate entity."""
super().__init__(coordinator, entry, system_zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]}"
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
@ -209,7 +209,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
await self._async_update_hvac_params(params)
if slave_raise:
raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}")
raise HomeAssistantError(
f"Mode can't be changed on slave zone {self.entity_id}"
)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@ -221,6 +223,9 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW]
await self._async_update_hvac_params(params)
if ATTR_HVAC_MODE in kwargs:
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""

View File

@ -39,6 +39,8 @@ _LOGGER = logging.getLogger(__name__)
class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]):
"""Define an Airzone entity."""
_attr_has_entity_name = True
def get_airzone_value(self, key: str) -> Any:
"""Return Airzone entity value by key."""
raise NotImplementedError()
@ -62,7 +64,7 @@ class AirzoneSystemEntity(AirzoneEntity):
identifiers={(DOMAIN, f"{entry.entry_id}_{self.system_id}")},
manufacturer=MANUFACTURER,
model=self.get_airzone_value(AZD_MODEL),
name=self.get_airzone_value(AZD_FULL_NAME),
name=f"System {self.system_id}",
sw_version=self.get_airzone_value(AZD_FIRMWARE),
via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
@ -116,9 +118,7 @@ class AirzoneHotWaterEntity(AirzoneEntity):
try:
await self.coordinator.airzone.set_dhw_parameters(_params)
except AirzoneError as error:
raise HomeAssistantError(
f"Failed to set dhw {self.name}: {error}"
) from error
raise HomeAssistantError(f"Failed to set DHW: {error}") from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
@ -172,7 +172,7 @@ class AirzoneZoneEntity(AirzoneEntity):
identifiers={(DOMAIN, f"{entry.entry_id}_{system_zone_id}")},
manufacturer=MANUFACTURER,
model=self.get_airzone_value(AZD_THERMOSTAT_MODEL),
name=f"Airzone [{system_zone_id}] {zone_data[AZD_NAME]}",
name=zone_data[AZD_NAME],
sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW),
via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"),
)
@ -203,7 +203,7 @@ class AirzoneZoneEntity(AirzoneEntity):
await self.coordinator.airzone.set_hvac_parameters(_params)
except AirzoneError as error:
raise HomeAssistantError(
f"Failed to set zone {self.name}: {error}"
f"Failed to set zone {self.entity_id}: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())

View File

@ -11,7 +11,6 @@ from aioairzone.const import (
API_SLEEP,
AZD_COLD_ANGLE,
AZD_HEAT_ANGLE,
AZD_NAME,
AZD_SLEEP,
AZD_ZONES,
)
@ -60,7 +59,6 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
api_param=API_COLD_ANGLE,
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",
@ -69,16 +67,14 @@ ZONE_SELECT_TYPES: Final[tuple[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",
translation_key="heat_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",
@ -146,7 +142,6 @@ class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
"""Initialize."""
super().__init__(coordinator, entry, system_zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]} {description.name}"
self._attr_unique_id = (
f"{self._attr_unique_id}_{system_zone_id}_{description.key}"
)

View File

@ -6,7 +6,6 @@ from typing import Any, Final
from aioairzone.const import (
AZD_HOT_WATER,
AZD_HUMIDITY,
AZD_NAME,
AZD_TEMP,
AZD_TEMP_UNIT,
AZD_WEBSERVER,
@ -54,7 +53,7 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_WIFI_RSSI,
name="RSSI",
translation_key="rssi",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
),
@ -64,14 +63,12 @@ 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,
),
@ -144,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity):
class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor):
"""Define an Airzone Hot Water sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@ -176,7 +171,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor):
) -> None:
"""Initialize."""
super().__init__(coordinator, entry)
self._attr_name = f"WebServer {description.name}"
self._attr_unique_id = f"{self._attr_unique_id}_ws_{description.key}"
self.entity_description = description
self._async_update_attrs()
@ -196,7 +190,6 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor):
"""Initialize."""
super().__init__(coordinator, entry, system_zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]} {description.name}"
self._attr_unique_id = (
f"{self._attr_unique_id}_{system_zone_id}_{description.key}"
)

View File

@ -25,8 +25,17 @@
}
},
"entity": {
"binary_sensor": {
"air_demand": {
"name": "Air demand"
},
"floor_demand": {
"name": "Floor demand"
}
},
"select": {
"grille_angles": {
"name": "Cold angle",
"state": {
"90deg": "90°",
"50deg": "50°",
@ -34,7 +43,17 @@
"40deg": "40°"
}
},
"heat_angles": {
"name": "Heat angle",
"state": {
"90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]",
"50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]",
"45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]",
"40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]"
}
},
"sleep_times": {
"name": "Sleep",
"state": {
"off": "[%key:common::state::off%]",
"30m": "30 minutes",
@ -42,6 +61,11 @@
"90m": "90 minutes"
}
}
},
"sensor": {
"rssi": {
"name": "RSSI"
}
}
}
}

View File

@ -9,7 +9,6 @@ from aioairzone.const import (
API_ACS_POWER_MODE,
API_ACS_SET_POINT,
AZD_HOT_WATER,
AZD_NAME,
AZD_OPERATION,
AZD_OPERATIONS,
AZD_TEMP,
@ -67,6 +66,7 @@ async def async_setup_entry(
class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity):
"""Define an Airzone Water Heater."""
_attr_name = None
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.ON_OFF
@ -81,7 +81,6 @@ class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity):
"""Initialize Airzone water heater entity."""
super().__init__(coordinator, entry)
self._attr_name = self.get_airzone_value(AZD_NAME)
self._attr_unique_id = f"{self._attr_unique_id}_dhw"
self._attr_operation_list = [
OPERATION_LIB_TO_HASS[operation]

View File

@ -46,7 +46,9 @@ 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)
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.airzone.logout()
return unload_ok

View File

@ -159,8 +159,6 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity):
class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor):
"""Define an Airzone Cloud Aidoo binary sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@ -180,8 +178,6 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor):
class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor):
"""Define an Airzone Cloud System binary sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@ -201,8 +197,6 @@ class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor):
class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor):
"""Define an Airzone Cloud Zone binary sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,

View File

@ -32,6 +32,7 @@ from aioairzone_cloud.const import (
)
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@ -142,7 +143,6 @@ async def async_setup_entry(
class AirzoneClimate(AirzoneEntity, ClimateEntity):
"""Define an Airzone Cloud climate."""
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@ -205,6 +205,9 @@ class AirzoneDeviceClimate(AirzoneClimate):
}
await self._async_update_params(params)
if ATTR_HVAC_MODE in kwargs:
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
class AirzoneDeviceGroupClimate(AirzoneClimate):
"""Define an Airzone Cloud DeviceGroup base class."""
@ -239,6 +242,9 @@ class AirzoneDeviceGroupClimate(AirzoneClimate):
}
await self._async_update_params(params)
if ATTR_HVAC_MODE in kwargs:
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
params: dict[str, Any] = {
@ -387,4 +393,6 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate):
await self._async_update_params(params)
if slave_raise:
raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}")
raise HomeAssistantError(
f"Mode can't be changed on slave zone {self.entity_id}"
)

View File

@ -34,6 +34,8 @@ _LOGGER = logging.getLogger(__name__)
class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC):
"""Define an Airzone Cloud entity."""
_attr_has_entity_name = True
@property
def available(self) -> bool:
"""Return Airzone Cloud entity availability."""
@ -78,14 +80,14 @@ class AirzoneAidooEntity(AirzoneEntity):
async def _async_update_params(self, params: dict[str, Any]) -> None:
"""Send Aidoo parameters to Cloud API."""
_LOGGER.debug("aidoo=%s: update_params=%s", self.name, params)
_LOGGER.debug("aidoo=%s: update_params=%s", self.entity_id, params)
try:
await self.coordinator.airzone.api_set_aidoo_id_params(
self.aidoo_id, params
)
except AirzoneCloudError as error:
raise HomeAssistantError(
f"Failed to set {self.name} params: {error}"
f"Failed to set {self.entity_id} params: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
@ -120,14 +122,14 @@ class AirzoneGroupEntity(AirzoneEntity):
async def _async_update_params(self, params: dict[str, Any]) -> None:
"""Send Group parameters to Cloud API."""
_LOGGER.debug("group=%s: update_params=%s", self.name, params)
_LOGGER.debug("group=%s: update_params=%s", self.entity_id, params)
try:
await self.coordinator.airzone.api_set_group_id_params(
self.group_id, params
)
except AirzoneCloudError as error:
raise HomeAssistantError(
f"Failed to set {self.name} params: {error}"
f"Failed to set {self.entity_id} params: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
@ -162,14 +164,18 @@ class AirzoneInstallationEntity(AirzoneEntity):
async def _async_update_params(self, params: dict[str, Any]) -> None:
"""Send Installation parameters to Cloud API."""
_LOGGER.debug("installation=%s: update_params=%s", self.name, params)
_LOGGER.debug(
"installation=%s: update_params=%s",
self.entity_id,
params,
)
try:
await self.coordinator.airzone.api_set_installation_id_params(
self.inst_id, params
)
except AirzoneCloudError as error:
raise HomeAssistantError(
f"Failed to set {self.name} params: {error}"
f"Failed to set {self.entity_id} params: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
@ -265,12 +271,12 @@ class AirzoneZoneEntity(AirzoneEntity):
async def _async_update_params(self, params: dict[str, Any]) -> None:
"""Send Zone parameters to Cloud API."""
_LOGGER.debug("zone=%s: update_params=%s", self.name, params)
_LOGGER.debug("zone=%s: update_params=%s", self.entity_id, params)
try:
await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params)
except AirzoneCloudError as error:
raise HomeAssistantError(
f"Failed to set {self.name} params: {error}"
f"Failed to set {self.entity_id} params: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_polling",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.3.5"]
"requirements": ["aioairzone-cloud==0.3.6"]
}

View File

@ -141,8 +141,6 @@ class AirzoneSensor(AirzoneEntity, SensorEntity):
class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor):
"""Define an Airzone Cloud Aidoo sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@ -162,8 +160,6 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor):
class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor):
"""Define an Airzone Cloud WebServer sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@ -183,8 +179,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor):
class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor):
"""Define an Airzone Cloud Zone sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,

View File

@ -14,6 +14,10 @@
"port": "[%key:common::config_flow::data::port%]",
"device_baudrate": "Device Baud Rate",
"device_path": "Device Path"
},
"data_description": {
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
"port": "The port on which AlarmDecoder is accessible (for example, 10000)"
}
}
},

View File

@ -7,6 +7,9 @@
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server."
}
}
},

View File

@ -1,44 +1,34 @@
"""Support for APCUPSd via its Network Information Server (NIS)."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any, Final
from apcaccess import status
from typing import Final
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.util import Throttle
from .const import DOMAIN
from .coordinator import APCUPSdCoordinator
_LOGGER = logging.getLogger(__name__)
DOMAIN: Final = "apcupsd"
VALUE_ONLINE: Final = 8
PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR)
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=60)
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Use config values to set up a function enabling status retrieval."""
data_service = APCUPSdData(
config_entry.data[CONF_HOST], config_entry.data[CONF_PORT]
)
host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT]
coordinator = APCUPSdCoordinator(hass, host, port)
try:
await hass.async_add_executor_job(data_service.update)
except OSError as ex:
_LOGGER.error("Failure while testing APCUPSd status retrieval: %s", ex)
return False
await coordinator.async_config_entry_first_refresh()
# Store the data service object.
# Store the coordinator for later uses.
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config_entry.entry_id] = data_service
hass.data[DOMAIN][config_entry.entry_id] = coordinator
# Forward the config entries to the supported platforms.
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@ -51,66 +41,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok and DOMAIN in hass.data:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class APCUPSdData:
"""Stores the data retrieved from APCUPSd.
For each entity to use, acts as the single point responsible for fetching
updates from the server.
"""
def __init__(self, host: str, port: int) -> None:
"""Initialize the data object."""
self._host = host
self._port = port
self.status: dict[str, str] = {}
@property
def name(self) -> str | None:
"""Return the name of the UPS, if available."""
return self.status.get("UPSNAME")
@property
def model(self) -> str | None:
"""Return the model of the UPS, if available."""
# Different UPS models may report slightly different keys for model, here we
# try them all.
for model_key in ("APCMODEL", "MODEL"):
if model_key in self.status:
return self.status[model_key]
return None
@property
def serial_no(self) -> str | None:
"""Return the unique serial number of the UPS, if available."""
return self.status.get("SERIALNO")
@property
def statflag(self) -> str | None:
"""Return the STATFLAG indicating the status of the UPS, if available."""
return self.status.get("STATFLAG")
@property
def device_info(self) -> DeviceInfo | None:
"""Return the DeviceInfo of this APC UPS for the sensors, if serial number is available."""
if self.serial_no is None:
return None
return DeviceInfo(
identifiers={(DOMAIN, self.serial_no)},
model=self.model,
manufacturer="APC",
name=self.name if self.name is not None else "APC UPS",
hw_version=self.status.get("FIRMWARE"),
sw_version=self.status.get("VERSION"),
)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self, **kwargs: Any) -> None:
"""Fetch the latest status from APCUPSd.
Note that the result dict uses upper case for each resource, where our
integration uses lower cases as keys internally.
"""
self.status = status.parse(status.get(host=self._host, port=self._port))

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Final
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@ -10,8 +11,9 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN, VALUE_ONLINE, APCUPSdData
from . import DOMAIN, APCUPSdCoordinator
_LOGGER = logging.getLogger(__name__)
_DESCRIPTION = BinarySensorEntityDescription(
@ -19,6 +21,8 @@ _DESCRIPTION = BinarySensorEntityDescription(
name="UPS Online Status",
icon="mdi:heart",
)
# The bit in STATFLAG that indicates the online status of the APC UPS.
_VALUE_ONLINE_MASK: Final = 0b1000
async def async_setup_entry(
@ -27,50 +31,36 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up an APCUPSd Online Status binary sensor."""
data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id]
coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id]
# Do not create the binary sensor if APCUPSd does not provide STATFLAG field for us
# to determine the online status.
if data_service.statflag is None:
if _DESCRIPTION.key.upper() not in coordinator.data:
return
async_add_entities(
[OnlineStatus(data_service, _DESCRIPTION)],
update_before_add=True,
)
async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)])
class OnlineStatus(BinarySensorEntity):
class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
"""Representation of a UPS online status."""
def __init__(
self,
data_service: APCUPSdData,
coordinator: APCUPSdCoordinator,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize the APCUPSd binary device."""
super().__init__(coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := data_service.serial_no) is not None:
if (serial_no := coordinator.ups_serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self._attr_device_info = data_service.device_info
self.entity_description = description
self._data_service = data_service
self._attr_device_info = coordinator.device_info
def update(self) -> None:
"""Get the status report from APCUPSd and set this entity's state."""
try:
self._data_service.update()
except OSError as ex:
if self._attr_available:
self._attr_available = False
_LOGGER.exception("Got exception while fetching state: %s", ex)
return
self._attr_available = True
@property
def is_on(self) -> bool | None:
"""Returns true if the UPS is online."""
# Check if ONLINE bit is set in STATFLAG.
key = self.entity_description.key.upper()
if key not in self._data_service.status:
self._attr_is_on = None
return
self._attr_is_on = int(self._data_service.status[key], 16) & VALUE_ONLINE > 0
return int(self.coordinator.data[key], 16) & _VALUE_ONLINE_MASK != 0

View File

@ -1,6 +1,7 @@
"""Config flow for APCUPSd integration."""
from __future__ import annotations
import asyncio
from typing import Any
import voluptuous as vol
@ -10,8 +11,9 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import UpdateFailed
from . import DOMAIN, APCUPSdData
from . import DOMAIN, APCUPSdCoordinator
_PORT_SELECTOR = vol.All(
selector.NumberSelector(
@ -43,36 +45,37 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user", data_schema=_SCHEMA)
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
# Abort if an entry with same host and port is present.
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
# Test the connection to the host and get the current status for serial number.
data_service = APCUPSdData(user_input[CONF_HOST], user_input[CONF_PORT])
try:
await self.hass.async_add_executor_job(data_service.update)
except OSError:
coordinator = APCUPSdCoordinator(self.hass, host, port)
await coordinator.async_request_refresh()
await self.hass.async_block_till_done()
if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)):
errors = {"base": "cannot_connect"}
return self.async_show_form(
step_id="user", data_schema=_SCHEMA, errors=errors
)
if not data_service.status:
if not coordinator.data:
return self.async_abort(reason="no_status")
# We _try_ to use the serial number of the UPS as the unique id since this field
# is not guaranteed to exist on all APC UPS models.
await self.async_set_unique_id(data_service.serial_no)
await self.async_set_unique_id(coordinator.ups_serial_no)
self._abort_if_unique_id_configured()
title = "APC UPS"
if data_service.name is not None:
title = data_service.name
elif data_service.model is not None:
title = data_service.model
elif data_service.serial_no is not None:
title = data_service.serial_no
if coordinator.ups_name is not None:
title = coordinator.ups_name
elif coordinator.ups_model is not None:
title = coordinator.ups_model
elif coordinator.ups_serial_no is not None:
title = coordinator.ups_serial_no
return self.async_create_entry(
title=title,

View File

@ -0,0 +1,4 @@
"""Constants for APCUPSd component."""
from typing import Final
DOMAIN: Final = "apcupsd"

View File

@ -0,0 +1,102 @@
"""Support for APCUPSd via its Network Information Server (NIS)."""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from datetime import timedelta
import logging
from typing import Final
from apcaccess import status
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import (
REQUEST_REFRESH_DEFAULT_IMMEDIATE,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL: Final = timedelta(seconds=60)
REQUEST_REFRESH_COOLDOWN: Final = 5
class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
"""Store and coordinate the data retrieved from APCUPSd for all sensors.
For each entity to use, acts as the single point responsible for fetching
updates from the server.
"""
def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
"""Initialize the data object."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=REQUEST_REFRESH_COOLDOWN,
immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE,
),
)
self._host = host
self._port = port
@property
def ups_name(self) -> str | None:
"""Return the name of the UPS, if available."""
return self.data.get("UPSNAME")
@property
def ups_model(self) -> str | None:
"""Return the model of the UPS, if available."""
# Different UPS models may report slightly different keys for model, here we
# try them all.
for model_key in ("APCMODEL", "MODEL"):
if model_key in self.data:
return self.data[model_key]
return None
@property
def ups_serial_no(self) -> str | None:
"""Return the unique serial number of the UPS, if available."""
return self.data.get("SERIALNO")
@property
def device_info(self) -> DeviceInfo | None:
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
if not self.ups_serial_no:
return None
return DeviceInfo(
identifiers={(DOMAIN, self.ups_serial_no)},
model=self.ups_model,
manufacturer="APC",
name=self.ups_name if self.ups_name else "APC UPS",
hw_version=self.data.get("FIRMWARE"),
sw_version=self.data.get("VERSION"),
)
async def _async_update_data(self) -> OrderedDict[str, str]:
"""Fetch the latest status from APCUPSd.
Note that the result dict uses upper case for each resource, where our
integration uses lower cases as keys internally.
"""
async with asyncio.timeout(10):
try:
raw = await self.hass.async_add_executor_job(
status.get, self._host, self._port
)
result: OrderedDict[str, str] = status.parse(raw)
return result
except OSError as error:
raise UpdateFailed(error) from error

View File

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
"quality_scale": "silver",
"requirements": ["apcaccess==0.0.13"]
}

View File

@ -20,10 +20,11 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN, APCUPSdData
from . import DOMAIN, APCUPSdCoordinator
_LOGGER = logging.getLogger(__name__)
@ -452,11 +453,11 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the APCUPSd sensors from config entries."""
data_service: APCUPSdData = hass.data[DOMAIN][config_entry.entry_id]
coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id]
# The resources from data service are in upper-case by default, but we use
# lower cases throughout this integration.
available_resources: set[str] = {k.lower() for k, _ in data_service.status.items()}
# The resource keys in the data dict collected in the coordinator is in upper-case
# by default, but we use lower cases throughout this integration.
available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()}
entities = []
for resource in available_resources:
@ -464,9 +465,9 @@ async def async_setup_entry(
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
entities.append(APCUPSdSensor(data_service, SENSORS[resource]))
entities.append(APCUPSdSensor(coordinator, SENSORS[resource]))
async_add_entities(entities, update_before_add=True)
async_add_entities(entities)
def infer_unit(value: str) -> tuple[str, str | None]:
@ -483,41 +484,36 @@ def infer_unit(value: str) -> tuple[str, str | None]:
return value, None
class APCUPSdSensor(SensorEntity):
class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
"""Representation of a sensor entity for APCUPSd status values."""
def __init__(
self,
data_service: APCUPSdData,
coordinator: APCUPSdCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := data_service.serial_no) is not None:
if (serial_no := coordinator.ups_serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self._attr_device_info = data_service.device_info
self.entity_description = description
self._data_service = data_service
self._attr_device_info = coordinator.device_info
def update(self) -> None:
"""Get the latest status and use it to update our sensor state."""
try:
self._data_service.update()
except OSError as ex:
if self._attr_available:
self._attr_available = False
_LOGGER.exception("Got exception while fetching state: %s", ex)
return
# Initial update of attributes.
self._update_attrs()
self._attr_available = True
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_attrs()
self.async_write_ha_state()
def _update_attrs(self) -> None:
"""Update sensor attributes based on coordinator data."""
key = self.entity_description.key.upper()
if key not in self._data_service.status:
self._attr_native_value = None
return
self._attr_native_value, inferred_unit = infer_unit(
self._data_service.status[key]
)
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit

View File

@ -1,9 +1,11 @@
"""Rest API for Home Assistant."""
import asyncio
from asyncio import timeout
from asyncio import shield, timeout
from collections.abc import Collection
from functools import lru_cache
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import web
from aiohttp.web_exceptions import HTTPBadRequest
@ -16,6 +18,7 @@ from homeassistant.components.http import HomeAssistantView, require_admin
from homeassistant.const import (
CONTENT_TYPE_JSON,
EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED,
MATCH_ALL,
URL_API,
URL_API_COMPONENTS,
@ -38,10 +41,12 @@ from homeassistant.exceptions import (
Unauthorized,
)
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.event import EventStateChangedData
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import ConfigType, EventType
from homeassistant.util.json import json_loads
from homeassistant.util.read_only_dict import ReadOnlyDict
_LOGGER = logging.getLogger(__name__)
@ -57,6 +62,7 @@ ATTR_VERSION = "version"
DOMAIN = "api"
STREAM_PING_PAYLOAD = "ping"
STREAM_PING_INTERVAL = 50 # seconds
SERVICE_WAIT_TIMEOUT = 10
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@ -211,7 +217,9 @@ class APIStatesView(HomeAssistantView):
if entity_perm(state.entity_id, "read")
)
response = web.Response(
body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON
body=f'[{",".join(states)}]',
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
)
response.enable_compression()
return response
@ -369,19 +377,30 @@ class APIDomainServicesView(HomeAssistantView):
)
context = self.context(request)
changed_states: list[ReadOnlyDict[str, Collection[Any]]] = []
@ha.callback
def _async_save_changed_entities(
event: EventType[EventStateChangedData],
) -> None:
if event.context == context and (state := event.data["new_state"]):
changed_states.append(state.as_dict())
cancel_listen = hass.bus.async_listen(
EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True
)
try:
await hass.services.async_call(
domain, service, data, blocking=True, context=context
# shield the service call from cancellation on connection drop
await shield(
hass.services.async_call(
domain, service, data, blocking=True, context=context
)
)
except (vol.Invalid, ServiceNotFound) as ex:
raise HTTPBadRequest() from ex
changed_states = []
for state in hass.states.async_all():
if state.context is context:
changed_states.append(state)
finally:
cancel_listen()
return self.json(changed_states)

View File

@ -9,7 +9,13 @@ from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN
from .const import (
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
DATA_LAST_WAKE_UP,
DOMAIN,
EVENT_RECORDING,
)
from .error import PipelineNotFound
from .pipeline import (
AudioSettings,
@ -40,6 +46,7 @@ __all__ = (
"PipelineEventType",
"PipelineNotFound",
"WakeWordSettings",
"EVENT_RECORDING",
)
CONFIG_SCHEMA = vol.Schema(

View File

@ -11,3 +11,5 @@ CONF_DEBUG_RECORDING_DIR = "debug_recording_dir"
DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up"
DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds
EVENT_RECORDING = f"{DOMAIN}_recording"

View File

@ -0,0 +1,39 @@
"""Describe assist_pipeline logbook events."""
from __future__ import annotations
from collections.abc import Callable
from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import Event, HomeAssistant, callback
import homeassistant.helpers.device_registry as dr
from .const import DOMAIN, EVENT_RECORDING
@callback
def async_describe_events(
hass: HomeAssistant,
async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None],
) -> None:
"""Describe logbook events."""
device_registry = dr.async_get(hass)
@callback
def async_describe_logbook_event(event: Event) -> dict[str, str]:
"""Describe logbook event."""
device: dr.DeviceEntry | None = None
device_name: str = "Unknown device"
device = device_registry.devices[event.data[ATTR_DEVICE_ID]]
if device:
device_name = device.name_by_user or device.name or "Unknown device"
message = f"{device_name} captured an audio sample"
return {
LOGBOOK_ENTRY_NAME: device_name,
LOGBOOK_ENTRY_MESSAGE: message,
}
async_describe_event(DOMAIN, EVENT_RECORDING, async_describe_logbook_event)

View File

@ -320,7 +320,7 @@ class Pipeline:
wake_word_entity: str | None
wake_word_id: str | None
id: str = field(default_factory=ulid_util.ulid)
id: str = field(default_factory=ulid_util.ulid_now)
@classmethod
def from_json(cls, data: dict[str, Any]) -> Pipeline:
@ -482,7 +482,7 @@ class PipelineRun:
wake_word_settings: WakeWordSettings | None = None
audio_settings: AudioSettings = field(default_factory=AudioSettings)
id: str = field(default_factory=ulid_util.ulid)
id: str = field(default_factory=ulid_util.ulid_now)
stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False)
tts_engine: str = field(init=False, repr=False)
tts_options: dict | None = field(init=False, default=None)
@ -503,6 +503,9 @@ class PipelineRun:
audio_processor_buffer: AudioBuffer = field(init=False, repr=False)
"""Buffer used when splitting audio into chunks for audio processing"""
_device_id: str | None = None
"""Optional device id set during run start."""
def __post_init__(self) -> None:
"""Set language for pipeline."""
self.language = self.pipeline.language or self.hass.config.language
@ -554,7 +557,8 @@ class PipelineRun:
def start(self, device_id: str | None) -> None:
"""Emit run start event."""
self._start_debug_recording_thread(device_id)
self._device_id = device_id
self._start_debug_recording_thread()
data = {
"pipeline": self.pipeline.id,
@ -567,6 +571,9 @@ class PipelineRun:
async def end(self) -> None:
"""Emit run end event."""
# Signal end of stream to listeners
self._capture_chunk(None)
# Stop the recording thread before emitting run-end.
# This ensures that files are properly closed if the event handler reads them.
await self._stop_debug_recording_thread()
@ -746,9 +753,7 @@ class PipelineRun:
if self.abort_wake_word_detection:
raise WakeWordDetectionAborted
if self.debug_recording_queue is not None:
self.debug_recording_queue.put_nowait(chunk.audio)
self._capture_chunk(chunk.audio)
yield chunk.audio, chunk.timestamp_ms
# Wake-word-detection occurs *after* the wake word was actually
@ -870,8 +875,7 @@ class PipelineRun:
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
sent_vad_start = False
async for chunk in audio_stream:
if self.debug_recording_queue is not None:
self.debug_recording_queue.put_nowait(chunk.audio)
self._capture_chunk(chunk.audio)
if stt_vad is not None:
if not stt_vad.process(chunk_seconds, chunk.is_speech):
@ -971,12 +975,16 @@ class PipelineRun:
# pipeline.tts_engine can't be None or this function is not called
engine = cast(str, self.pipeline.tts_engine)
tts_options = {}
tts_options: dict[str, Any] = {}
if self.pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice
if self.tts_audio_output is not None:
tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output
tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output
if self.tts_audio_output == "wav":
# 16 Khz, 16-bit mono
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1
try:
options_supported = await tts.async_support_options(
@ -1016,44 +1024,64 @@ class PipelineRun:
)
)
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
if tts_input := tts_input.strip():
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media)
_LOGGER.debug("TTS result %s", tts_media)
tts_output = {
"media_id": tts_media_id,
**asdict(tts_media),
}
else:
tts_output = {}
self.process_event(
PipelineEvent(
PipelineEventType.TTS_END,
{
"tts_output": {
"media_id": tts_media_id,
**asdict(tts_media),
}
},
)
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
)
return tts_media.url
def _start_debug_recording_thread(self, device_id: str | None) -> None:
def _capture_chunk(self, audio_bytes: bytes | None) -> None:
"""Forward audio chunk to various capturing mechanisms."""
if self.debug_recording_queue is not None:
# Forward to debug WAV file recording
self.debug_recording_queue.put_nowait(audio_bytes)
if self._device_id is None:
return
# Forward to device audio capture
pipeline_data: PipelineData = self.hass.data[DOMAIN]
audio_queue = pipeline_data.device_audio_queues.get(self._device_id)
if audio_queue is None:
return
try:
audio_queue.queue.put_nowait(audio_bytes)
except asyncio.QueueFull:
audio_queue.overflow = True
_LOGGER.warning("Audio queue full for device %s", self._device_id)
def _start_debug_recording_thread(self) -> None:
"""Start thread to record wake/stt audio if debug_recording_dir is set."""
if self.debug_recording_thread is not None:
# Already started
@ -1064,7 +1092,7 @@ class PipelineRun:
if debug_recording_dir := self.hass.data[DATA_CONFIG].get(
CONF_DEBUG_RECORDING_DIR
):
if device_id is None:
if self._device_id is None:
# <debug_recording_dir>/<pipeline.name>/<run.id>
run_recording_dir = (
Path(debug_recording_dir)
@ -1075,7 +1103,7 @@ class PipelineRun:
# <debug_recording_dir>/<device_id>/<pipeline.name>/<run.id>
run_recording_dir = (
Path(debug_recording_dir)
/ device_id
/ self._device_id
/ self.pipeline.name
/ str(time.monotonic_ns())
)
@ -1096,8 +1124,8 @@ class PipelineRun:
# Not running
return
# Signal thread to stop gracefully
self.debug_recording_queue.put(None)
# NOTE: Expecting a None to have been put in self.debug_recording_queue
# in self.end() to signal the thread to stop.
# Wait until the thread has finished to ensure that files are fully written
await self.hass.async_add_executor_job(self.debug_recording_thread.join)
@ -1286,9 +1314,9 @@ class PipelineInput:
if stt_audio_buffer:
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> AsyncGenerator[
ProcessedAudioChunk, None
]:
async def buffer_then_audio_stream() -> (
AsyncGenerator[ProcessedAudioChunk, None]
):
# Buffered audio
for chunk in stt_audio_buffer:
yield chunk
@ -1447,7 +1475,7 @@ class PipelineStorageCollection(
@callback
def _get_suggested_id(self, info: dict) -> str:
"""Suggest an ID based on the config."""
return ulid_util.ulid()
return ulid_util.ulid_now()
async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline:
"""Return a new updated item."""
@ -1628,6 +1656,20 @@ class PipelineRuns:
pipeline_run.abort_wake_word_detection = True
@dataclass
class DeviceAudioQueue:
"""Audio capture queue for a satellite device."""
queue: asyncio.Queue[bytes | None]
"""Queue of audio chunks (None = stop signal)"""
id: str = field(default_factory=ulid_util.ulid_now)
"""Unique id to ensure the correct audio queue is cleaned up in websocket API."""
overflow: bool = False
"""Flag to be set if audio samples were dropped because the queue was full."""
class PipelineData:
"""Store and debug data stored in hass.data."""
@ -1637,6 +1679,7 @@ class PipelineData:
self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {}
self.pipeline_devices: set[str] = set()
self.pipeline_runs = PipelineRuns(pipeline_store)
self.device_audio_queues: dict[str, DeviceAudioQueue] = {}
@dataclass

View File

@ -93,9 +93,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
if self.registry_entry and (device_id := self.registry_entry.device_id):
pipeline_data.pipeline_devices.add(device_id)
self.async_on_remove(
lambda: pipeline_data.pipeline_devices.discard(
device_id # type: ignore[arg-type]
)
lambda: pipeline_data.pipeline_devices.discard(device_id)
)
async def async_select_option(self, option: str) -> None:

View File

@ -3,22 +3,31 @@ import asyncio
# Suppressing disable=deprecated-module is needed for Python 3.11
import audioop # pylint: disable=deprecated-module
import base64
from collections.abc import AsyncGenerator, Callable
import contextlib
import logging
from typing import Any
import math
from typing import Any, Final
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
from homeassistant.const import MATCH_ALL
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util import language as language_util
from .const import DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, DOMAIN
from .const import (
DEFAULT_PIPELINE_TIMEOUT,
DEFAULT_WAKE_WORD_TIMEOUT,
DOMAIN,
EVENT_RECORDING,
)
from .error import PipelineNotFound
from .pipeline import (
AudioSettings,
DeviceAudioQueue,
PipelineData,
PipelineError,
PipelineEvent,
@ -32,6 +41,11 @@ from .pipeline import (
_LOGGER = logging.getLogger(__name__)
CAPTURE_RATE: Final = 16000
CAPTURE_WIDTH: Final = 2
CAPTURE_CHANNELS: Final = 1
MAX_CAPTURE_TIMEOUT: Final = 60.0
@callback
def async_register_websocket_api(hass: HomeAssistant) -> None:
@ -40,6 +54,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_languages)
websocket_api.async_register_command(hass, websocket_list_runs)
websocket_api.async_register_command(hass, websocket_get_run)
websocket_api.async_register_command(hass, websocket_device_capture)
@websocket_api.websocket_command(
@ -371,3 +386,100 @@ async def websocket_list_languages(
else pipeline_languages
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "assist_pipeline/device/capture",
vol.Required("device_id"): str,
vol.Required("timeout"): vol.All(
# 0 < timeout <= MAX_CAPTURE_TIMEOUT
vol.Coerce(float),
vol.Range(min=0, min_included=False, max=MAX_CAPTURE_TIMEOUT),
),
}
)
@websocket_api.async_response
async def websocket_device_capture(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Capture raw audio from a satellite device and forward to client."""
pipeline_data: PipelineData = hass.data[DOMAIN]
device_id = msg["device_id"]
# Number of seconds to record audio in wall clock time
timeout_seconds = msg["timeout"]
# We don't know the chunk size, so the upper bound is calculated assuming a
# single sample (16 bits) per queue item.
max_queue_items = (
# +1 for None to signal end
int(math.ceil(timeout_seconds * CAPTURE_RATE)) + 1
)
audio_queue = DeviceAudioQueue(queue=asyncio.Queue(maxsize=max_queue_items))
# Running simultaneous captures for a single device will not work by design.
# The new capture will cause the old capture to stop.
if (
old_audio_queue := pipeline_data.device_audio_queues.pop(device_id, None)
) is not None:
with contextlib.suppress(asyncio.QueueFull):
# Signal other websocket command that we're taking over
old_audio_queue.queue.put_nowait(None)
# Only one client can be capturing audio at a time
pipeline_data.device_audio_queues[device_id] = audio_queue
def clean_up_queue() -> None:
# Clean up our audio queue
maybe_audio_queue = pipeline_data.device_audio_queues.get(device_id)
if (maybe_audio_queue is not None) and (maybe_audio_queue.id == audio_queue.id):
# Only pop if this is our queue
pipeline_data.device_audio_queues.pop(device_id)
# Unsubscribe cleans up queue
connection.subscriptions[msg["id"]] = clean_up_queue
# Audio will follow as events
connection.send_result(msg["id"])
# Record to logbook
hass.bus.async_fire(
EVENT_RECORDING,
{
ATTR_DEVICE_ID: device_id,
ATTR_SECONDS: timeout_seconds,
},
)
try:
with contextlib.suppress(asyncio.TimeoutError):
async with asyncio.timeout(timeout_seconds):
while True:
# Send audio chunks encoded as base64
audio_bytes = await audio_queue.queue.get()
if audio_bytes is None:
# Signal to stop
break
connection.send_event(
msg["id"],
{
"type": "audio",
"rate": CAPTURE_RATE, # hertz
"width": CAPTURE_WIDTH, # bytes
"channels": CAPTURE_CHANNELS,
"audio": base64.b64encode(audio_bytes).decode("ascii"),
},
)
# Capture has ended
connection.send_event(
msg["id"], {"type": "end", "overflow": audio_queue.overflow}
)
finally:
clean_up_queue()

View File

@ -3,10 +3,14 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections import namedtuple
from collections.abc import Awaitable, Callable, Coroutine
import functools
import logging
from typing import Any, cast
from typing import Any, TypeVar, cast
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
from aiohttp import ClientSession
from pyasuswrt import AsusWrtError, AsusWrtHttp
from homeassistant.const import (
CONF_HOST,
@ -17,6 +21,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import UpdateFailed
@ -29,6 +34,8 @@ from .const import (
DEFAULT_INTERFACE,
KEY_METHOD,
KEY_SENSORS,
PROTOCOL_HTTP,
PROTOCOL_HTTPS,
PROTOCOL_TELNET,
SENSORS_BYTES,
SENSORS_LOAD_AVG,
@ -47,9 +54,40 @@ WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"])
_LOGGER = logging.getLogger(__name__)
def _get_dict(keys: list, values: list) -> dict[str, Any]:
"""Create a dict from a list of keys and values."""
return dict(zip(keys, values))
_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge")
_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]]
_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]]
def handle_errors_and_zip(
exceptions: type[Exception] | tuple[type[Exception], ...], keys: list[str] | None
) -> Callable[[_FuncType], _ReturnFuncType]:
"""Run library methods and zip results or manage exceptions."""
def _handle_errors_and_zip(func: _FuncType) -> _ReturnFuncType:
"""Run library methods and zip results or manage exceptions."""
@functools.wraps(func)
async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, Any]:
try:
data = await func(self)
except exceptions as exc:
raise UpdateFailed(exc) from exc
if keys is None:
if not isinstance(data, dict):
raise UpdateFailed("Received invalid data type")
return data
if isinstance(data, dict):
return dict(zip(keys, list(data.values())))
if not isinstance(data, list):
raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data))
return _wrapper
return _handle_errors_and_zip
class AsusWrtBridge(ABC):
@ -60,6 +98,9 @@ class AsusWrtBridge(ABC):
hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None
) -> AsusWrtBridge:
"""Get Bridge instance."""
if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP):
session = async_get_clientsession(hass)
return AsusWrtHttpBridge(conf, session)
return AsusWrtLegacyBridge(conf, options)
def __init__(self, host: str) -> None:
@ -236,38 +277,135 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
availability = await self._api.async_find_temperature_commands()
return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]]
async def _get_bytes(self) -> dict[str, Any]:
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES)
async def _get_bytes(self) -> Any:
"""Fetch byte information from the router."""
try:
datas = await self._api.async_get_bytes_total()
except (IndexError, OSError, ValueError) as exc:
raise UpdateFailed(exc) from exc
return await self._api.async_get_bytes_total()
return _get_dict(SENSORS_BYTES, datas)
async def _get_rates(self) -> dict[str, Any]:
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_RATES)
async def _get_rates(self) -> Any:
"""Fetch rates information from the router."""
try:
rates = await self._api.async_get_current_transfer_rates()
except (IndexError, OSError, ValueError) as exc:
raise UpdateFailed(exc) from exc
return await self._api.async_get_current_transfer_rates()
return _get_dict(SENSORS_RATES, rates)
async def _get_load_avg(self) -> dict[str, Any]:
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_LOAD_AVG)
async def _get_load_avg(self) -> Any:
"""Fetch load average information from the router."""
try:
avg = await self._api.async_get_loadavg()
except (IndexError, OSError, ValueError) as exc:
raise UpdateFailed(exc) from exc
return await self._api.async_get_loadavg()
return _get_dict(SENSORS_LOAD_AVG, avg)
async def _get_temperatures(self) -> dict[str, Any]:
@handle_errors_and_zip((OSError, ValueError), None)
async def _get_temperatures(self) -> Any:
"""Fetch temperatures information from the router."""
try:
temperatures: dict[str, Any] = await self._api.async_get_temperature()
except (OSError, ValueError) as exc:
raise UpdateFailed(exc) from exc
return await self._api.async_get_temperature()
return temperatures
class AsusWrtHttpBridge(AsusWrtBridge):
"""The Bridge that use HTTP library."""
def __init__(self, conf: dict[str, Any], session: ClientSession) -> None:
"""Initialize Bridge that use HTTP library."""
super().__init__(conf[CONF_HOST])
self._api: AsusWrtHttp = self._get_api(conf, session)
@staticmethod
def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp:
"""Get the AsusWrtHttp API."""
return AsusWrtHttp(
conf[CONF_HOST],
conf[CONF_USERNAME],
conf.get(CONF_PASSWORD, ""),
use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS,
port=conf.get(CONF_PORT),
session=session,
)
@property
def is_connected(self) -> bool:
"""Get connected status."""
return cast(bool, self._api.is_connected)
async def async_connect(self) -> None:
"""Connect to the device."""
await self._api.async_connect()
# get main router properties
if mac := self._api.mac:
self._label_mac = format_mac(mac)
self._firmware = self._api.firmware
self._model = self._api.model
async def async_disconnect(self) -> None:
"""Disconnect to the device."""
await self._api.async_disconnect()
async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
"""Get list of connected devices."""
try:
api_devices = await self._api.async_get_connected_devices()
except AsusWrtError as exc:
raise UpdateFailed(exc) from exc
return {
format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node)
for mac, dev in api_devices.items()
}
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
"""Return a dictionary of available sensors for this bridge."""
sensors_temperatures = await self._get_available_temperature_sensors()
sensors_types = {
SENSORS_TYPE_BYTES: {
KEY_SENSORS: SENSORS_BYTES,
KEY_METHOD: self._get_bytes,
},
SENSORS_TYPE_LOAD_AVG: {
KEY_SENSORS: SENSORS_LOAD_AVG,
KEY_METHOD: self._get_load_avg,
},
SENSORS_TYPE_RATES: {
KEY_SENSORS: SENSORS_RATES,
KEY_METHOD: self._get_rates,
},
SENSORS_TYPE_TEMPERATURES: {
KEY_SENSORS: sensors_temperatures,
KEY_METHOD: self._get_temperatures,
},
}
return sensors_types
async def _get_available_temperature_sensors(self) -> list[str]:
"""Check which temperature information is available on the router."""
try:
available_temps = await self._api.async_get_temperatures()
available_sensors = [
t for t in SENSORS_TEMPERATURES if t in available_temps
]
except AsusWrtError as exc:
_LOGGER.warning(
(
"Failed checking temperature sensor availability for ASUS router"
" %s. Exception: %s"
),
self.host,
exc,
)
return []
return available_sensors
@handle_errors_and_zip(AsusWrtError, SENSORS_BYTES)
async def _get_bytes(self) -> Any:
"""Fetch byte information from the router."""
return await self._api.async_get_traffic_bytes()
@handle_errors_and_zip(AsusWrtError, SENSORS_RATES)
async def _get_rates(self) -> Any:
"""Fetch rates information from the router."""
return await self._api.async_get_traffic_rates()
@handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG)
async def _get_load_avg(self) -> Any:
"""Fetch cpu load avg information from the router."""
return await self._api.async_get_loadavg()
@handle_errors_and_zip(AsusWrtError, None)
async def _get_temperatures(self) -> Any:
"""Fetch temperatures information from the router."""
return await self._api.async_get_temperatures()

View File

@ -7,6 +7,7 @@ import os
import socket
from typing import Any, cast
from pyasuswrt import AsusWrtError
import voluptuous as vol
from homeassistant.components.device_tracker import (
@ -15,6 +16,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import (
CONF_BASE,
CONF_HOST,
CONF_MODE,
CONF_PASSWORD,
@ -30,6 +32,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .bridge import AsusWrtBridge
from .const import (
@ -44,11 +47,21 @@ from .const import (
DOMAIN,
MODE_AP,
MODE_ROUTER,
PROTOCOL_HTTP,
PROTOCOL_HTTPS,
PROTOCOL_SSH,
PROTOCOL_TELNET,
)
LABEL_MAC = "LABEL_MAC"
ALLOWED_PROTOCOL = [
PROTOCOL_HTTPS,
PROTOCOL_SSH,
PROTOCOL_HTTP,
PROTOCOL_TELNET,
]
PASS_KEY = "pass_key"
PASS_KEY_MSG = "Only provide password or SSH key file"
RESULT_CONN_ERROR = "cannot_connect"
RESULT_SUCCESS = "success"
@ -56,14 +69,20 @@ RESULT_UNKNOWN = "unknown"
_LOGGER = logging.getLogger(__name__)
LEGACY_SCHEMA = vol.Schema(
{
vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In(
{MODE_ROUTER: "Router", MODE_AP: "Access Point"}
),
}
)
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(
CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds()
): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)),
vol.Optional(CONF_TRACK_UNKNOWN, default=DEFAULT_TRACK_UNKNOWN): bool,
vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str,
vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str,
}
)
@ -72,12 +91,22 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Get options schema."""
options_flow: SchemaOptionsFlowHandler
options_flow = cast(SchemaOptionsFlowHandler, handler.parent_handler)
if options_flow.config_entry.data[CONF_MODE] == MODE_AP:
return OPTIONS_SCHEMA.extend(
used_protocol = options_flow.config_entry.data[CONF_PROTOCOL]
if used_protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]:
data_schema = OPTIONS_SCHEMA.extend(
{
vol.Optional(CONF_REQUIRE_IP, default=True): bool,
vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): str,
vol.Required(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): str,
}
)
if options_flow.config_entry.data[CONF_MODE] == MODE_AP:
return data_schema.extend(
{
vol.Optional(CONF_REQUIRE_IP, default=True): bool,
}
)
return data_schema
return OPTIONS_SCHEMA
@ -101,45 +130,47 @@ def _get_ip(host: str) -> str | None:
class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
"""Handle a config flow for AsusWRT."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the AsusWrt config flow."""
self._config_data: dict[str, Any] = {}
@callback
def _show_setup_form(
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, str] | None = None,
) -> FlowResult:
def _show_setup_form(self, error: str | None = None) -> FlowResult:
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
user_input = self._config_data
adv_schema = {}
conf_password = vol.Required(CONF_PASSWORD)
if self.show_advanced_options:
conf_password = vol.Optional(CONF_PASSWORD)
adv_schema[vol.Optional(CONF_PORT)] = cv.port
adv_schema[vol.Optional(CONF_SSH_KEY)] = str
add_schema = {
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
vol.Optional(CONF_PORT): cv.port,
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
}
else:
add_schema = {vol.Required(CONF_PASSWORD): str}
schema = {
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str,
conf_password: str,
vol.Required(CONF_PROTOCOL, default=PROTOCOL_SSH): vol.In(
{PROTOCOL_SSH: "SSH", PROTOCOL_TELNET: "Telnet"}
),
**adv_schema,
vol.Required(CONF_MODE, default=MODE_ROUTER): vol.In(
{MODE_ROUTER: "Router", MODE_AP: "Access Point"}
**add_schema,
vol.Required(
CONF_PROTOCOL,
default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS),
): SelectSelector(
SelectSelectorConfig(
options=ALLOWED_PROTOCOL, translation_key="protocols"
)
),
}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema),
errors=errors or {},
errors={CONF_BASE: error} if error else None,
)
async def _async_check_connection(
@ -147,25 +178,49 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
) -> tuple[str, str | None]:
"""Attempt to connect the AsusWrt router."""
api: AsusWrtBridge
host: str = user_input[CONF_HOST]
api = AsusWrtBridge.get_bridge(self.hass, user_input)
protocol = user_input[CONF_PROTOCOL]
error: str | None = None
conf = {**user_input, CONF_MODE: MODE_ROUTER}
api = AsusWrtBridge.get_bridge(self.hass, conf)
try:
await api.async_connect()
except OSError:
_LOGGER.error("Error connecting to the AsusWrt router at %s", host)
return RESULT_CONN_ERROR, None
except (AsusWrtError, OSError):
_LOGGER.error(
"Error connecting to the AsusWrt router at %s using protocol %s",
host,
protocol,
)
error = RESULT_CONN_ERROR
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unknown error connecting with AsusWrt router at %s", host
"Unknown error connecting with AsusWrt router at %s using protocol %s",
host,
protocol,
)
return RESULT_UNKNOWN, None
error = RESULT_UNKNOWN
if not api.is_connected:
_LOGGER.error("Error connecting to the AsusWrt router at %s", host)
return RESULT_CONN_ERROR, None
if error is None:
if not api.is_connected:
_LOGGER.error(
"Error connecting to the AsusWrt router at %s using protocol %s",
host,
protocol,
)
error = RESULT_CONN_ERROR
if error is not None:
return error, None
_LOGGER.info(
"Successfully connected to the AsusWrt router at %s using protocol %s",
host,
protocol,
)
unique_id = api.label_mac
await api.async_disconnect()
@ -182,51 +237,59 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_unique_id")
if user_input is None:
return self._show_setup_form(user_input)
errors: dict[str, str] = {}
host: str = user_input[CONF_HOST]
return self._show_setup_form()
self._config_data = user_input
pwd: str | None = user_input.get(CONF_PASSWORD)
ssh: str | None = user_input.get(CONF_SSH_KEY)
protocol: str = user_input[CONF_PROTOCOL]
if not pwd and protocol != PROTOCOL_SSH:
return self._show_setup_form(error="pwd_required")
if not (pwd or ssh):
errors["base"] = "pwd_or_ssh"
elif ssh:
if pwd:
errors["base"] = "pwd_and_ssh"
return self._show_setup_form(error="pwd_or_ssh")
if ssh and not await self.hass.async_add_executor_job(_is_file, ssh):
return self._show_setup_form(error="ssh_not_file")
host: str = user_input[CONF_HOST]
if not await self.hass.async_add_executor_job(_get_ip, host):
return self._show_setup_form(error="invalid_host")
result, unique_id = await self._async_check_connection(user_input)
if result == RESULT_SUCCESS:
if unique_id:
await self.async_set_unique_id(unique_id)
# we allow to configure a single instance without unique id
elif self._async_current_entries():
return self.async_abort(reason="invalid_unique_id")
else:
isfile = await self.hass.async_add_executor_job(_is_file, ssh)
if not isfile:
errors["base"] = "ssh_not_file"
if not errors:
ip_address = await self.hass.async_add_executor_job(_get_ip, host)
if not ip_address:
errors["base"] = "invalid_host"
if not errors:
result, unique_id = await self._async_check_connection(user_input)
if result == RESULT_SUCCESS:
if unique_id:
await self.async_set_unique_id(unique_id)
# we allow configure a single instance without unique id
elif self._async_current_entries():
return self.async_abort(reason="invalid_unique_id")
else:
_LOGGER.warning(
"This device does not provide a valid Unique ID."
" Configuration of multiple instance will not be possible"
)
return self.async_create_entry(
title=host,
data=user_input,
_LOGGER.warning(
"This device does not provide a valid Unique ID."
" Configuration of multiple instance will not be possible"
)
errors["base"] = result
if protocol in [PROTOCOL_SSH, PROTOCOL_TELNET]:
return await self.async_step_legacy()
return await self._async_save_entry()
return self._show_setup_form(user_input, errors)
return self._show_setup_form(error=result)
async def async_step_legacy(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow for legacy settings."""
if user_input is None:
return self.async_show_form(step_id="legacy", data_schema=LEGACY_SCHEMA)
self._config_data.update(user_input)
return await self._async_save_entry()
async def _async_save_entry(self) -> FlowResult:
"""Save entry data if unique id is valid."""
return self.async_create_entry(
title=self._config_data[CONF_HOST],
data=self._config_data,
)
@staticmethod
@callback

View File

@ -20,6 +20,8 @@ KEY_SENSORS = "sensors"
MODE_AP = "ap"
MODE_ROUTER = "router"
PROTOCOL_HTTP = "http"
PROTOCOL_HTTPS = "https"
PROTOCOL_SSH = "ssh"
PROTOCOL_TELNET = "telnet"

View File

@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics(
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
hass_device = device_registry.async_get_device(
identifiers=router.device_info["identifiers"]
identifiers=router.device_info[ATTR_IDENTIFIERS]
)
if not hass_device:
return data

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asyncssh"],
"requirements": ["aioasuswrt==1.4.0"]
"requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.20"]
}

View File

@ -6,6 +6,8 @@ from datetime import datetime, timedelta
import logging
from typing import Any
from pyasuswrt import AsusWrtError
from homeassistant.components.device_tracker import (
CONF_CONSIDER_HOME,
DEFAULT_CONSIDER_HOME,
@ -219,7 +221,7 @@ class AsusWrtRouter:
"""Set up a AsusWrt router."""
try:
await self._api.async_connect()
except OSError as exc:
except (AsusWrtError, OSError) as exc:
raise ConfigEntryNotReady from exc
if not self._api.is_connected:
raise ConfigEntryNotReady

View File

@ -2,25 +2,31 @@
"config": {
"step": {
"user": {
"title": "AsusWRT",
"description": "Set required parameter to connect to your router",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "[%key:common::config_flow::data::name%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"ssh_key": "Path to your SSH key file (instead of password)",
"protocol": "Communication protocol to use",
"port": "Port (leave empty for protocol default)",
"mode": "[%key:common::config_flow::data::mode%]"
"port": "Port (leave empty for protocol default)"
},
"data_description": {
"host": "The hostname or IP address of your ASUSWRT router."
}
},
"legacy": {
"description": "Set required parameters to connect to your router",
"data": {
"mode": "Router operating mode"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"pwd_and_ssh": "Only provide password or SSH key file",
"pwd_or_ssh": "Please provide password or SSH key file",
"pwd_required": "Password is required for selected protocol",
"ssh_not_file": "SSH key file not found",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
@ -32,7 +38,6 @@
"options": {
"step": {
"init": {
"title": "AsusWRT Options",
"data": {
"consider_home": "Seconds to wait before considering a device away",
"track_unknown": "Track unknown / unnamed devices",
@ -79,5 +84,15 @@
"name": "CPU Temperature"
}
}
},
"selector": {
"protocols": {
"options": {
"https": "HTTPS",
"http": "HTTP",
"ssh": "SSH",
"telnet": "Telnet"
}
}
}
}

View File

@ -2,10 +2,13 @@
"config": {
"step": {
"user": {
"title": "Connect to the device",
"description": "Connect to the device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Atag device."
}
}
},

View File

@ -2,10 +2,12 @@
from abc import abstractmethod
from yalexs.doorbell import Doorbell
from yalexs.lock import Lock
from yalexs.lock import Lock, LockDetail
from yalexs.util import get_configuration_url
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@ -26,15 +28,18 @@ class AugustEntityMixin(Entity):
super().__init__()
self._data = data
self._device = device
detail = self._detail
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
manufacturer=MANUFACTURER,
model=self._detail.model,
model=detail.model,
name=device.device_name,
sw_version=self._detail.firmware_version,
sw_version=detail.firmware_version,
suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES),
configuration_url=get_configuration_url(data.brand),
)
if isinstance(detail, LockDetail) and (mac := detail.mac_address):
self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)}
@property
def _device_id(self):

View File

@ -12,13 +12,14 @@
import logging
from aurorapy.client import AuroraSerialClient
from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
from .const import DOMAIN, SCAN_INTERVAL
PLATFORMS = [Platform.SENSOR]
@ -30,8 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
comport = entry.data[CONF_PORT]
address = entry.data[CONF_ADDRESS]
ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client
coordinator = AuroraAbbDataUpdateCoordinator(hass, comport, address)
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
@ -47,3 +50,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
"""Class to manage fetching AuroraAbbPowerone data."""
def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None:
"""Initialize the data update coordinator."""
self.available_prev = False
self.available = False
self.client = AuroraSerialClient(address, comport, parity="N", timeout=1)
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
def _update_data(self) -> dict[str, float]:
"""Fetch new state data for the sensor.
This is the only function that should fetch new data for Home Assistant.
"""
data: dict[str, float] = {}
self.available_prev = self.available
try:
self.client.connect()
# read ADC channel 3 (grid power output)
power_watts = self.client.measure(3, True)
temperature_c = self.client.measure(21)
energy_wh = self.client.cumulated_energy(5)
except AuroraTimeoutError:
self.available = False
_LOGGER.debug("No response from inverter (could be dark)")
except AuroraError as error:
self.available = False
raise error
else:
data["instantaneouspower"] = round(power_watts, 1)
data["temp"] = round(temperature_c, 1)
data["totalenergy"] = round(energy_wh / 1000, 2)
self.available = True
finally:
if self.available != self.available_prev:
if self.available:
_LOGGER.info("Communication with %s back online", self.name)
else:
_LOGGER.warning(
"Communication with %s lost",
self.name,
)
if self.client.serline.isOpen():
self.client.close()
return data
async def _async_update_data(self) -> dict[str, float]:
"""Update inverter data in the executor."""
return await self.hass.async_add_executor_job(self._update_data)

View File

@ -1,57 +0,0 @@
"""Top level class for AuroraABBPowerOneSolarPV inverters and sensors."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from aurorapy.client import AuroraSerialClient
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import (
ATTR_DEVICE_NAME,
ATTR_FIRMWARE,
ATTR_MODEL,
ATTR_SERIAL_NUMBER,
DEFAULT_DEVICE_NAME,
DOMAIN,
MANUFACTURER,
)
_LOGGER = logging.getLogger(__name__)
class AuroraEntity(Entity):
"""Representation of an Aurora ABB PowerOne device."""
def __init__(self, client: AuroraSerialClient, data: Mapping[str, Any]) -> None:
"""Initialise the basic device."""
self._data = data
self.type = "device"
self.client = client
self._available = True
@property
def unique_id(self) -> str | None:
"""Return the unique id for this device."""
if (serial := self._data.get(ATTR_SERIAL_NUMBER)) is None:
return None
return f"{serial}_{self.entity_description.key}"
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
identifiers={(DOMAIN, self._data[ATTR_SERIAL_NUMBER])},
manufacturer=MANUFACTURER,
model=self._data[ATTR_MODEL],
name=self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME),
sw_version=self._data[ATTR_FIRMWARE],
)

View File

@ -1,5 +1,7 @@
"""Constants for the Aurora ABB PowerOne integration."""
from datetime import timedelta
DOMAIN = "aurora_abb_powerone"
# Min max addresses and default according to here:
@ -8,6 +10,7 @@ DOMAIN = "aurora_abb_powerone"
MIN_ADDRESS = 2
MAX_ADDRESS = 63
DEFAULT_ADDRESS = 2
SCAN_INTERVAL = timedelta(seconds=30)
DEFAULT_INTEGRATION_TITLE = "PhotoVoltaic Inverters"
DEFAULT_DEVICE_NAME = "Solar Inverter"

View File

@ -5,8 +5,6 @@ from collections.abc import Mapping
import logging
from typing import Any
from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@ -21,10 +19,21 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .aurora_device import AuroraEntity
from .const import DOMAIN
from . import AuroraAbbDataUpdateCoordinator
from .const import (
ATTR_DEVICE_NAME,
ATTR_FIRMWARE,
ATTR_MODEL,
ATTR_SERIAL_NUMBER,
DEFAULT_DEVICE_NAME,
DOMAIN,
MANUFACTURER,
)
_LOGGER = logging.getLogger(__name__)
@ -61,70 +70,40 @@ async def async_setup_entry(
"""Set up aurora_abb_powerone sensor based on a config entry."""
entities = []
client = hass.data[DOMAIN][config_entry.entry_id]
coordinator = hass.data[DOMAIN][config_entry.entry_id]
data = config_entry.data
for sens in SENSOR_TYPES:
entities.append(AuroraSensor(client, data, sens))
entities.append(AuroraSensor(coordinator, data, sens))
_LOGGER.debug("async_setup_entry adding %d entities", len(entities))
async_add_entities(entities, True)
class AuroraSensor(AuroraEntity, SensorEntity):
"""Representation of a Sensor on a Aurora ABB PowerOne Solar inverter."""
class AuroraSensor(CoordinatorEntity[AuroraAbbDataUpdateCoordinator], SensorEntity):
"""Representation of a Sensor on an Aurora ABB PowerOne Solar inverter."""
_attr_has_entity_name = True
def __init__(
self,
client: AuroraSerialClient,
coordinator: AuroraAbbDataUpdateCoordinator,
data: Mapping[str, Any],
entity_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(client, data)
super().__init__(coordinator)
self.entity_description = entity_description
self.available_prev = True
self._attr_unique_id = f"{data[ATTR_SERIAL_NUMBER]}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data[ATTR_SERIAL_NUMBER])},
manufacturer=MANUFACTURER,
model=data[ATTR_MODEL],
name=data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME),
sw_version=data[ATTR_FIRMWARE],
)
def update(self) -> None:
"""Fetch new state data for the sensor.
This is the only method that should fetch new data for Home Assistant.
"""
try:
self.available_prev = self._attr_available
self.client.connect()
if self.entity_description.key == "instantaneouspower":
# read ADC channel 3 (grid power output)
power_watts = self.client.measure(3, True)
self._attr_native_value = round(power_watts, 1)
elif self.entity_description.key == "temp":
temperature_c = self.client.measure(21)
self._attr_native_value = round(temperature_c, 1)
elif self.entity_description.key == "totalenergy":
energy_wh = self.client.cumulated_energy(5)
self._attr_native_value = round(energy_wh / 1000, 2)
self._attr_available = True
except AuroraTimeoutError:
self._attr_state = None
self._attr_native_value = None
self._attr_available = False
_LOGGER.debug("No response from inverter (could be dark)")
except AuroraError as error:
self._attr_state = None
self._attr_native_value = None
self._attr_available = False
raise error
finally:
if self._attr_available != self.available_prev:
if self._attr_available:
_LOGGER.info("Communication with %s back online", self.name)
else:
_LOGGER.warning(
"Communication with %s lost",
self.name,
)
if self.client.serline.isOpen():
self.client.close()
@property
def native_value(self) -> StateType:
"""Get the value of the sensor from previously collected data."""
return self.coordinator.data.get(self.entity_description.key)

View File

@ -71,14 +71,14 @@ from __future__ import annotations
from collections.abc import Callable
from http import HTTPStatus
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from aiohttp import web
import voluptuous as vol
import voluptuous_serialize
from homeassistant import data_entry_flow
from homeassistant.auth import AuthManagerFlowManager
from homeassistant.auth import AuthManagerFlowManager, InvalidAuthError
from homeassistant.auth.models import Credentials
from homeassistant.components import onboarding
from homeassistant.components.http.auth import async_user_not_allowed_do_auth
@ -90,10 +90,16 @@ from homeassistant.components.http.ban import (
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.util.network import is_local
from . import indieauth
if TYPE_CHECKING:
from homeassistant.auth.providers.trusted_networks import (
TrustedNetworksAuthProvider,
)
from . import StoreResultType
@ -146,12 +152,61 @@ class AuthProvidersView(HomeAssistantView):
message_code="onboarding_required",
)
return self.json(
[
{"name": provider.name, "id": provider.id, "type": provider.type}
for provider in hass.auth.auth_providers
]
)
try:
remote_address = ip_address(request.remote) # type: ignore[arg-type]
except ValueError:
return self.json_message(
message="Invalid remote IP",
status_code=HTTPStatus.BAD_REQUEST,
message_code="invalid_remote_ip",
)
cloud_connection = is_cloud_connection(hass)
providers = []
for provider in hass.auth.auth_providers:
additional_data = {}
if provider.type == "trusted_networks":
if cloud_connection:
# Skip quickly as trusted networks are not available on cloud
continue
try:
cast("TrustedNetworksAuthProvider", provider).async_validate_access(
remote_address
)
except InvalidAuthError:
# Not a trusted network, so we don't expose that trusted_network authenticator is setup
continue
elif (
provider.type == "homeassistant"
and not cloud_connection
and is_local(remote_address)
and "person" in hass.config.components
):
# We are local, return user id and username
users = await provider.store.async_get_users()
additional_data["users"] = {
user.id: credentials.data["username"]
for user in users
for credentials in user.credentials
if (
credentials.auth_provider_type == provider.type
and credentials.auth_provider_id == provider.id
)
}
providers.append(
{
"name": provider.name,
"id": provider.id,
"type": provider.type,
**additional_data,
}
)
return self.json(providers)
def _prepare_result_json(
@ -235,7 +290,7 @@ class LoginFlowBaseView(HomeAssistantView):
f"Login blocked: {user_access_error}", HTTPStatus.FORBIDDEN
)
await process_success_login(request)
process_success_login(request)
result["result"] = self._store_result(client_id, result_obj)
return self.json(result)

View File

@ -31,5 +31,11 @@
"invalid_code": "Invalid code, please try again."
}
}
},
"issues": {
"deprecated_legacy_api_password": {
"title": "The legacy API password is deprecated",
"description": "The legacy API password authentication provider is deprecated and will be removed. Please remove it from your YAML configuration and use the default Home Assistant authentication provider instead."
}
}
}

View File

@ -1,5 +1,6 @@
"""Helpers for automation integration."""
from homeassistant.components import blueprint
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
@ -15,8 +16,17 @@ def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
return len(automations_with_blueprint(hass, blueprint_path)) > 0
async def _reload_blueprint_automations(
hass: HomeAssistant, blueprint_path: str
) -> None:
"""Reload all automations that rely on a specific blueprint."""
await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
@singleton(DATA_BLUEPRINTS)
@callback
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
"""Get automation blueprints."""
return blueprint.DomainBlueprints(hass, DOMAIN, LOGGER, _blueprint_in_use)
return blueprint.DomainBlueprints(
hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_automations
)

View File

@ -3,12 +3,16 @@
"flow_title": "{name} ({host})",
"step": {
"user": {
"title": "Set up Axis device",
"description": "Set up an Axis device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Axis device.",
"username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant."
}
}
},

View File

@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_AUTO:
raise ValueError(f"Invalid preset mode: {preset_mode}")
self._device.fan_mode = OffOnAuto.AUTO
async def async_set_direction(self, direction: str) -> None:

View File

@ -47,31 +47,27 @@ class BalboaBinarySensorEntityDescription(
):
"""A class that describes Balboa binary sensor entities."""
# BalboaBinarySensorEntity does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off")
BINARY_SENSOR_DESCRIPTIONS = (
BalboaBinarySensorEntityDescription(
key="filter_cycle_1",
name="Filter1",
key="Filter1",
translation_key="filter_1",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda spa: spa.filter_cycle_1_running,
on_off_icons=FILTER_CYCLE_ICONS,
),
BalboaBinarySensorEntityDescription(
key="filter_cycle_2",
name="Filter2",
key="Filter2",
translation_key="filter_2",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda spa: spa.filter_cycle_2_running,
on_off_icons=FILTER_CYCLE_ICONS,
),
)
CIRCULATION_PUMP_DESCRIPTION = BalboaBinarySensorEntityDescription(
key="circulation_pump",
name="Circ Pump",
key="Circ Pump",
translation_key="circ_pump",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda spa: (pump := spa.circulation_pump) is not None and pump.state > 0,
on_off_icons=("mdi:pump", "mdi:pump-off"),
@ -87,7 +83,7 @@ class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity):
self, spa: SpaClient, description: BalboaBinarySensorEntityDescription
) -> None:
"""Initialize a Balboa binary sensor entity."""
super().__init__(spa, description.name)
super().__init__(spa, description.key)
self.entity_description = description
@property

View File

@ -59,6 +59,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_translation_key = DOMAIN
_attr_name = None
def __init__(self, client: SpaClient) -> None:
"""Initialize the climate entity."""

View File

@ -15,12 +15,11 @@ class BalboaEntity(Entity):
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, client: SpaClient, name: str | None = None) -> None:
def __init__(self, client: SpaClient, key: str) -> None:
"""Initialize the control."""
mac = client.mac_address
model = client.model
self._attr_unique_id = f'{model}-{name}-{mac.replace(":","")[-6:]}'
self._attr_name = name
self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}'
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
name=model,

View File

@ -2,9 +2,12 @@
"config": {
"step": {
"user": {
"title": "Connect to the Balboa Wi-Fi device",
"description": "Connect to the Balboa Wi-Fi device",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58."
}
}
},
@ -26,6 +29,17 @@
}
},
"entity": {
"binary_sensor": {
"filter_1": {
"name": "Filter cycle 1"
},
"filter_2": {
"name": "Filter cycle 2"
},
"circ_pump": {
"name": "Circulation pump"
}
},
"climate": {
"balboa": {
"state_attributes": {

View File

@ -10,8 +10,9 @@ from typing import Literal, final
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
@ -190,6 +191,14 @@ class BinarySensorEntity(Entity):
_attr_is_on: bool | None = None
_attr_state: None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the binary sensor entity is added to hass."""
await super().async_internal_added_to_hass()
if self.entity_category == EntityCategory.CONFIG:
raise HomeAssistantError(
f"Entity {self.entity_id} cannot be added as the entity category is set to config"
)
def _default_to_device_class_name(self) -> bool:
"""Return True if an unnamed entity should be named by its device class.

View File

@ -112,7 +112,7 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured()
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self.context.update(
{
"title_placeholders": {

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