mirror of
https://github.com/home-assistant/core.git
synced 2025-08-10 16:15:08 +02:00
Merge branch 'dev' into bangolufsen
This commit is contained in:
29
.coveragerc
29
.coveragerc
@@ -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
|
||||
@@ -128,6 +125,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/*
|
||||
@@ -149,6 +147,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
|
||||
@@ -221,9 +220,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
|
||||
@@ -343,7 +339,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
|
||||
@@ -374,7 +369,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
|
||||
@@ -413,6 +409,9 @@ omit =
|
||||
homeassistant/components/fjaraskupan/sensor.py
|
||||
homeassistant/components/fleetgo/device_tracker.py
|
||||
homeassistant/components/flexit/climate.py
|
||||
homeassistant/components/flexit_bacnet/__init__.py
|
||||
homeassistant/components/flexit_bacnet/const.py
|
||||
homeassistant/components/flexit_bacnet/climate.py
|
||||
homeassistant/components/flic/binary_sensor.py
|
||||
homeassistant/components/flick_electric/__init__.py
|
||||
homeassistant/components/flick_electric/sensor.py
|
||||
@@ -431,9 +430,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
|
||||
@@ -644,8 +641,6 @@ omit =
|
||||
homeassistant/components/kodi/browse_media.py
|
||||
homeassistant/components/kodi/media_player.py
|
||||
homeassistant/components/kodi/notify.py
|
||||
homeassistant/components/komfovent/__init__.py
|
||||
homeassistant/components/komfovent/climate.py
|
||||
homeassistant/components/konnected/__init__.py
|
||||
homeassistant/components/konnected/panel.py
|
||||
homeassistant/components/konnected/switch.py
|
||||
@@ -840,6 +835,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
|
||||
@@ -940,6 +936,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
|
||||
@@ -953,8 +952,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
|
||||
@@ -1136,10 +1133,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
|
||||
@@ -1480,6 +1474,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
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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:
|
||||
|
66
.github/workflows/ci.yaml
vendored
66
.github/workflows/ci.yaml
vendored
@@ -36,8 +36,7 @@ env:
|
||||
CACHE_VERSION: 5
|
||||
PIP_CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 6
|
||||
BLACK_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2023.12"
|
||||
HA_SHORT_VERSION: "2024.1"
|
||||
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 \
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2.22.5
|
||||
uses: github/codeql-action/init@v2.22.8
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2.22.5
|
||||
uses: github/codeql-action/analyze@v2.22.8
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -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"
|
||||
|
30
.github/workflows/matchers/ruff.json
vendored
30
.github/workflows/matchers/ruff.json
vendored
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -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.11.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
|
||||
|
@@ -121,6 +121,7 @@ homeassistant.components.energy.*
|
||||
homeassistant.components.esphome.*
|
||||
homeassistant.components.event.*
|
||||
homeassistant.components.evil_genius_labs.*
|
||||
homeassistant.components.faa_delays.*
|
||||
homeassistant.components.fan.*
|
||||
homeassistant.components.fastdotcom.*
|
||||
homeassistant.components.feedreader.*
|
||||
@@ -128,6 +129,7 @@ homeassistant.components.file_upload.*
|
||||
homeassistant.components.filesize.*
|
||||
homeassistant.components.filter.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.fritz.*
|
||||
@@ -203,6 +205,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.*
|
||||
@@ -264,6 +267,7 @@ homeassistant.components.proximity.*
|
||||
homeassistant.components.prusalink.*
|
||||
homeassistant.components.pure_energie.*
|
||||
homeassistant.components.purpleair.*
|
||||
homeassistant.components.pushbullet.*
|
||||
homeassistant.components.pvoutput.*
|
||||
homeassistant.components.qnap_qsw.*
|
||||
homeassistant.components.radarr.*
|
||||
|
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "ms-python.python"]
|
||||
"recommendations": [
|
||||
"charliermarsh.ruff",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
|
1
.vscode/settings.default.json
vendored
1
.vscode/settings.default.json
vendored
@@ -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"],
|
||||
|
55
CODEOWNERS
55
CODEOWNERS
@@ -153,8 +153,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
|
||||
@@ -172,8 +172,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
|
||||
@@ -261,6 +261,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
|
||||
@@ -309,8 +311,8 @@ 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/ @w1ll1am23
|
||||
@@ -347,17 +349,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
|
||||
@@ -375,7 +375,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
|
||||
@@ -396,6 +397,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fivem/ @Sander0542
|
||||
/homeassistant/components/fjaraskupan/ @elupus
|
||||
/tests/components/fjaraskupan/ @elupus
|
||||
/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/tests/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/homeassistant/components/flick_electric/ @ZephireNZ
|
||||
/tests/components/flick_electric/ @ZephireNZ
|
||||
/homeassistant/components/flipr/ @cnico
|
||||
@@ -492,8 +495,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
|
||||
@@ -666,8 +667,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/komfovent/ @ProstoSanja
|
||||
/tests/components/komfovent/ @ProstoSanja
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
@@ -703,6 +702,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
|
||||
@@ -931,6 +932,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
|
||||
@@ -945,6 +948,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
|
||||
@@ -981,6 +986,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
|
||||
@@ -1054,7 +1061,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
|
||||
@@ -1235,8 +1242,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
|
||||
@@ -1321,8 +1328,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
|
||||
@@ -1343,8 +1350,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
|
||||
@@ -1393,8 +1400,8 @@ build.json @home-assistant/supervisor
|
||||
/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/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
@@ -1,3 +1,6 @@
|
||||
# Automatically generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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})
|
||||
|
@@ -134,3 +134,5 @@ class UserMeta(NamedTuple):
|
||||
|
||||
name: str | None
|
||||
is_active: bool
|
||||
group: str | None = None
|
||||
local_only: bool | None = None
|
||||
|
@@ -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: … }
|
||||
|
@@ -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):
|
||||
|
@@ -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"
|
||||
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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(
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "eq3",
|
||||
"name": "eQ-3",
|
||||
"integrations": ["eq3btsmart", "maxcube"]
|
||||
"integrations": ["maxcube"]
|
||||
}
|
||||
|
5
homeassistant/brands/flexit.json
Normal file
5
homeassistant/brands/flexit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "flexit",
|
||||
"name": "Flexit",
|
||||
"integrations": ["flexit", "flexit_bacnet"]
|
||||
}
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["accuweather==2.1.0"]
|
||||
"requirements": ["accuweather==2.1.1"]
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.6.2"]
|
||||
"requirements": ["adguardhome==0.6.3"]
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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": {
|
||||
|
@@ -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",
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.2.4"]
|
||||
"requirements": ["aioairq==0.3.1"]
|
||||
}
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -857,16 +857,18 @@ class AlexaInputController(AlexaCapability):
|
||||
|
||||
def inputs(self) -> list[dict[str, str]] | None:
|
||||
"""Return the list of valid supported inputs."""
|
||||
source_list: list[str] = self.entity.attributes.get(
|
||||
source_list: list[Any] = self.entity.attributes.get(
|
||||
media_player.ATTR_INPUT_SOURCE_LIST, []
|
||||
)
|
||||
return AlexaInputController.get_valid_inputs(source_list)
|
||||
|
||||
@staticmethod
|
||||
def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]:
|
||||
def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]:
|
||||
"""Return list of supported inputs."""
|
||||
input_list: list[dict[str, str]] = []
|
||||
for source in source_list:
|
||||
if not isinstance(source, str):
|
||||
continue
|
||||
formatted_source = (
|
||||
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
)
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
4
homeassistant/components/apcupsd/const.py
Normal file
4
homeassistant/components/apcupsd/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for APCUPSd component."""
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "apcupsd"
|
102
homeassistant/components/apcupsd/coordinator.py
Normal file
102
homeassistant/components/apcupsd/coordinator.py
Normal 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.config_entries import ConfigEntry
|
||||
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.
|
||||
"""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
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:
|
||||
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)},
|
||||
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
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -41,7 +41,6 @@ from homeassistant.exceptions import (
|
||||
Unauthorized,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, template
|
||||
from homeassistant.helpers.aiohttp_compat import enable_compression
|
||||
from homeassistant.helpers.event import EventStateChangedData
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
@@ -218,9 +217,11 @@ 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,
|
||||
)
|
||||
enable_compression(response)
|
||||
response.enable_compression()
|
||||
return response
|
||||
|
||||
|
||||
@@ -390,17 +391,14 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
)
|
||||
|
||||
try:
|
||||
async with timeout(SERVICE_WAIT_TIMEOUT):
|
||||
# shield the service call from cancellation on connection drop
|
||||
await shield(
|
||||
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
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
cancel_listen()
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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"
|
||||
|
39
homeassistant/components/assist_pipeline/logbook.py
Normal file
39
homeassistant/components/assist_pipeline/logbook.py
Normal 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)
|
@@ -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):
|
||||
@@ -1020,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
|
||||
@@ -1068,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)
|
||||
@@ -1079,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())
|
||||
)
|
||||
@@ -1100,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)
|
||||
@@ -1290,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
|
||||
@@ -1451,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."""
|
||||
@@ -1632,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."""
|
||||
|
||||
@@ -1641,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
|
||||
|
@@ -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()
|
||||
|
@@ -9,6 +9,8 @@ import logging
|
||||
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,
|
||||
@@ -19,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
|
||||
|
||||
@@ -31,6 +34,8 @@ from .const import (
|
||||
DEFAULT_INTERFACE,
|
||||
KEY_METHOD,
|
||||
KEY_SENSORS,
|
||||
PROTOCOL_HTTP,
|
||||
PROTOCOL_HTTPS,
|
||||
PROTOCOL_TELNET,
|
||||
SENSORS_BYTES,
|
||||
SENSORS_LOAD_AVG,
|
||||
@@ -74,6 +79,8 @@ def handle_errors_and_zip(
|
||||
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))
|
||||
@@ -91,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:
|
||||
@@ -286,3 +296,116 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
|
||||
async def _get_temperatures(self) -> Any:
|
||||
"""Fetch temperatures information from the router."""
|
||||
return await self._api.async_get_temperature()
|
||||
|
||||
|
||||
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()
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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)
|
||||
|
@@ -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],
|
||||
)
|
@@ -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"
|
||||
|
@@ -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)
|
||||
|
@@ -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(
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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."""
|
||||
|
@@ -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,
|
||||
|
@@ -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": {
|
||||
|
@@ -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": {
|
||||
|
@@ -21,17 +21,11 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
SERVICE_REFRESH,
|
||||
SERVICE_SAVE_RECENT_CLIPS,
|
||||
SERVICE_SAVE_VIDEO,
|
||||
SERVICE_SEND_PIN,
|
||||
)
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
from .services import setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +37,8 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def _reauth_flow_wrapper(hass, data):
|
||||
"""Reauth flow wrapper."""
|
||||
@@ -75,6 +71,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Blink."""
|
||||
|
||||
setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Blink via config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
@@ -105,40 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
async def blink_refresh(event_time=None):
|
||||
"""Call blink to refresh info."""
|
||||
await coordinator.api.refresh(force_cache=True)
|
||||
|
||||
async def async_save_video(call):
|
||||
"""Call save video service handler."""
|
||||
await async_handle_save_video_service(hass, entry, call)
|
||||
|
||||
async def async_save_recent_clips(call):
|
||||
"""Call save recent clips service handler."""
|
||||
await async_handle_save_recent_clips_service(hass, entry, call)
|
||||
|
||||
async def send_pin(call):
|
||||
"""Call blink to send new pin."""
|
||||
pin = call.data[CONF_PIN]
|
||||
await coordinator.api.auth.send_auth_key(
|
||||
hass.data[DOMAIN][entry.entry_id].api,
|
||||
pin,
|
||||
)
|
||||
|
||||
hass.services.async_register(DOMAIN, SERVICE_REFRESH, blink_refresh)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SAVE_RECENT_CLIPS,
|
||||
async_save_recent_clips,
|
||||
schema=SERVICE_SAVE_RECENT_CLIPS_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -158,13 +128,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Blink entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if not hass.data[DOMAIN]:
|
||||
return True
|
||||
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@@ -172,37 +135,3 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
blink: Blink = hass.data[DOMAIN][entry.entry_id].api
|
||||
blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL]
|
||||
|
||||
|
||||
async def async_handle_save_video_service(
|
||||
hass: HomeAssistant, entry: ConfigEntry, call
|
||||
) -> None:
|
||||
"""Handle save video service calls."""
|
||||
camera_name = call.data[CONF_NAME]
|
||||
video_path = call.data[CONF_FILENAME]
|
||||
if not hass.config.is_allowed_path(video_path):
|
||||
_LOGGER.error("Can't write %s, no access to path!", video_path)
|
||||
return
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].video_to_file(video_path)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write image to file: %s", err)
|
||||
|
||||
|
||||
async def async_handle_save_recent_clips_service(
|
||||
hass: HomeAssistant, entry: ConfigEntry, call
|
||||
) -> None:
|
||||
"""Save multiple recent clips to output directory."""
|
||||
camera_name = call.data[CONF_NAME]
|
||||
clips_dir = call.data[CONF_FILE_PATH]
|
||||
if not hass.config.is_allowed_path(clips_dir):
|
||||
_LOGGER.error("Can't write to directory %s, no access to path!", clips_dir)
|
||||
return
|
||||
all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write recent clips to directory: %s", err)
|
||||
|
@@ -104,4 +104,3 @@ class BlinkSyncModuleHA(
|
||||
raise HomeAssistantError("Blink failed to arm camera away") from er
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
self.async_write_ha_state()
|
||||
|
@@ -32,9 +32,11 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = (
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
# Camera Armed sensor is depreciated covered by switch and will be removed in 2023.6.
|
||||
BinarySensorEntityDescription(
|
||||
key=TYPE_CAMERA_ARMED,
|
||||
translation_key="camera_armed",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key=TYPE_MOTION_DETECTED,
|
||||
|
@@ -7,6 +7,7 @@ DEVICE_ID = "Home Assistant"
|
||||
CONF_MIGRATE = "migrate"
|
||||
CONF_CAMERA = "camera"
|
||||
CONF_ALARM_CONTROL_PANEL = "alarm_control_panel"
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
DEFAULT_BRAND = "Blink"
|
||||
DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com"
|
||||
DEFAULT_SCAN_INTERVAL = 300
|
||||
@@ -30,4 +31,5 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CAMERA,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
@@ -13,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
|
||||
class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
@@ -25,7 +26,7 @@ class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=30),
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
|
33
homeassistant/components/blink/diagnostics.py
Normal file
33
homeassistant/components/blink/diagnostics.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Diagnostics support for Blink."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from blinkpy.blinkpy import Blink
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {"serial", "macaddress", "username", "password", "token"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
api: Blink = hass.data[DOMAIN][config_entry.entry_id].api
|
||||
|
||||
data = {
|
||||
camera.name: dict(camera.attributes.items())
|
||||
for _, camera in api.cameras.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
|
||||
"cameras": async_redact_data(data, TO_REDACT),
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blink",
|
||||
"name": "Blink",
|
||||
"codeowners": ["@fronzbot"],
|
||||
"codeowners": ["@fronzbot", "@mkmer"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
|
175
homeassistant/components/blink/services.py
Normal file
175
homeassistant/components/blink/services.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Services for the Blink integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
CONF_FILE_PATH,
|
||||
CONF_FILENAME,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH,
|
||||
SERVICE_SAVE_RECENT_CLIPS,
|
||||
SERVICE_SAVE_VIDEO,
|
||||
SERVICE_SEND_PIN,
|
||||
)
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_FILENAME): cv.string,
|
||||
}
|
||||
)
|
||||
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_FILE_PATH): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Blink integration."""
|
||||
|
||||
def collect_coordinators(
|
||||
device_ids: list[str],
|
||||
) -> list[BlinkUpdateCoordinator]:
|
||||
config_entries: list[ConfigEntry] = []
|
||||
registry = dr.async_get(hass)
|
||||
for target in device_ids:
|
||||
device = registry.async_get(target)
|
||||
if device:
|
||||
device_entries: list[ConfigEntry] = []
|
||||
for entry_id in device.config_entries:
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry and entry.domain == DOMAIN:
|
||||
device_entries.append(entry)
|
||||
if not device_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device",
|
||||
translation_placeholders={"target": target, "domain": DOMAIN},
|
||||
)
|
||||
config_entries.extend(device_entries)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"target": target},
|
||||
)
|
||||
|
||||
coordinators: list[BlinkUpdateCoordinator] = []
|
||||
for config_entry in config_entries:
|
||||
if config_entry.state != ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": config_entry.title},
|
||||
)
|
||||
|
||||
coordinators.append(hass.data[DOMAIN][config_entry.entry_id])
|
||||
return coordinators
|
||||
|
||||
async def async_handle_save_video_service(call: ServiceCall) -> None:
|
||||
"""Handle save video service calls."""
|
||||
camera_name = call.data[CONF_NAME]
|
||||
video_path = call.data[CONF_FILENAME]
|
||||
if not hass.config.is_allowed_path(video_path):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_path",
|
||||
translation_placeholders={"target": video_path},
|
||||
)
|
||||
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
all_cameras = coordinator.api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].video_to_file(video_path)
|
||||
except OSError as err:
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cant_write",
|
||||
) from err
|
||||
|
||||
async def async_handle_save_recent_clips_service(call: ServiceCall) -> None:
|
||||
"""Save multiple recent clips to output directory."""
|
||||
camera_name = call.data[CONF_NAME]
|
||||
clips_dir = call.data[CONF_FILE_PATH]
|
||||
if not hass.config.is_allowed_path(clips_dir):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_path",
|
||||
translation_placeholders={"target": clips_dir},
|
||||
)
|
||||
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
all_cameras = coordinator.api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].save_recent_clips(
|
||||
output_dir=clips_dir
|
||||
)
|
||||
except OSError as err:
|
||||
raise ServiceValidationError(
|
||||
str(err),
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cant_write",
|
||||
) from err
|
||||
|
||||
async def send_pin(call: ServiceCall):
|
||||
"""Call blink to send new pin."""
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
await coordinator.api.auth.send_auth_key(
|
||||
coordinator.api,
|
||||
call.data[CONF_PIN],
|
||||
)
|
||||
|
||||
async def blink_refresh(call: ServiceCall):
|
||||
"""Call blink to refresh info."""
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
await coordinator.api.refresh(force_cache=True)
|
||||
|
||||
# Register all the above services
|
||||
service_mapping = [
|
||||
(blink_refresh, SERVICE_REFRESH, None),
|
||||
(
|
||||
async_handle_save_video_service,
|
||||
SERVICE_SAVE_VIDEO,
|
||||
SERVICE_SAVE_VIDEO_SCHEMA,
|
||||
),
|
||||
(
|
||||
async_handle_save_recent_clips_service,
|
||||
SERVICE_SAVE_RECENT_CLIPS,
|
||||
SERVICE_SAVE_RECENT_CLIPS_SCHEMA,
|
||||
),
|
||||
(send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA),
|
||||
]
|
||||
|
||||
for service_handler, service_name, schema in service_mapping:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_name,
|
||||
service_handler,
|
||||
schema=schema,
|
||||
)
|
@@ -47,6 +47,11 @@
|
||||
"camera_armed": {
|
||||
"name": "Camera armed"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"camera_motion": {
|
||||
"name": "Camera motion detection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -96,5 +101,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_device": {
|
||||
"message": "Device '{target}' is not a {domain} device"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Device '{target}' not found in device registry"
|
||||
},
|
||||
"no_path": {
|
||||
"message": "Can't write to directory {target}, no access to path!"
|
||||
},
|
||||
"cant_write": {
|
||||
"message": "Can't write to file"
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
99
homeassistant/components/blink/switch.py
Normal file
99
homeassistant/components/blink/switch.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Support for Blink Motion detection switches."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_BRAND, DOMAIN, TYPE_CAMERA_ARMED
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
|
||||
SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
|
||||
SwitchEntityDescription(
|
||||
key=TYPE_CAMERA_ARMED,
|
||||
icon="mdi:motion-sensor",
|
||||
translation_key="camera_motion",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Blink switches."""
|
||||
coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
BlinkSwitch(coordinator, camera, description)
|
||||
for camera in coordinator.api.cameras
|
||||
for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
|
||||
class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
|
||||
"""Representation of a Blink motion detection switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BlinkUpdateCoordinator,
|
||||
camera,
|
||||
description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._camera = coordinator.api.cameras[camera]
|
||||
self.entity_description = description
|
||||
serial = self._camera.serial
|
||||
self._attr_unique_id = f"{serial}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
serial_number=serial,
|
||||
name=camera,
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
model=self._camera.camera_type,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self._camera.async_arm(True)
|
||||
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError(
|
||||
"Blink failed to arm camera motion detection"
|
||||
) from er
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
try:
|
||||
await self._camera.async_arm(False)
|
||||
|
||||
except asyncio.TimeoutError as er:
|
||||
raise HomeAssistantError(
|
||||
"Blink failed to dis-arm camera motion detection"
|
||||
) from er
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if Camera Motion is enabled."""
|
||||
return self._camera.motion_enabled
|
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
import pathlib
|
||||
import shutil
|
||||
@@ -189,12 +189,14 @@ class DomainBlueprints:
|
||||
domain: str,
|
||||
logger: logging.Logger,
|
||||
blueprint_in_use: Callable[[HomeAssistant, str], bool],
|
||||
reload_blueprint_consumers: Callable[[HomeAssistant, str], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Initialize a domain blueprints instance."""
|
||||
self.hass = hass
|
||||
self.domain = domain
|
||||
self.logger = logger
|
||||
self._blueprint_in_use = blueprint_in_use
|
||||
self._reload_blueprint_consumers = reload_blueprint_consumers
|
||||
self._blueprints: dict[str, Blueprint | None] = {}
|
||||
self._load_lock = asyncio.Lock()
|
||||
|
||||
@@ -283,7 +285,7 @@ class DomainBlueprints:
|
||||
blueprint = await self.hass.async_add_executor_job(
|
||||
self._load_blueprint, blueprint_path
|
||||
)
|
||||
except Exception:
|
||||
except FailedToLoad:
|
||||
self._blueprints[blueprint_path] = None
|
||||
raise
|
||||
|
||||
@@ -315,31 +317,41 @@ class DomainBlueprints:
|
||||
await self.hass.async_add_executor_job(path.unlink)
|
||||
self._blueprints[blueprint_path] = None
|
||||
|
||||
def _create_file(self, blueprint: Blueprint, blueprint_path: str) -> None:
|
||||
"""Create blueprint file."""
|
||||
def _create_file(
|
||||
self, blueprint: Blueprint, blueprint_path: str, allow_override: bool
|
||||
) -> bool:
|
||||
"""Create blueprint file.
|
||||
|
||||
Returns true if the action overrides an existing blueprint.
|
||||
"""
|
||||
|
||||
path = pathlib.Path(
|
||||
self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path)
|
||||
)
|
||||
if path.exists():
|
||||
exists = path.exists()
|
||||
|
||||
if not allow_override and exists:
|
||||
raise FileAlreadyExists(self.domain, blueprint_path)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(blueprint.yaml(), encoding="utf-8")
|
||||
return exists
|
||||
|
||||
async def async_add_blueprint(
|
||||
self, blueprint: Blueprint, blueprint_path: str
|
||||
) -> None:
|
||||
self, blueprint: Blueprint, blueprint_path: str, allow_override=False
|
||||
) -> bool:
|
||||
"""Add a blueprint."""
|
||||
if not blueprint_path.endswith(".yaml"):
|
||||
blueprint_path = f"{blueprint_path}.yaml"
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self._create_file, blueprint, blueprint_path
|
||||
overrides_existing = await self.hass.async_add_executor_job(
|
||||
self._create_file, blueprint, blueprint_path, allow_override
|
||||
)
|
||||
|
||||
self._blueprints[blueprint_path] = blueprint
|
||||
|
||||
if overrides_existing:
|
||||
await self._reload_blueprint_consumers(self.hass, blueprint_path)
|
||||
|
||||
return overrides_existing
|
||||
|
||||
async def async_populate(self) -> None:
|
||||
"""Create folder if it doesn't exist and populate with examples."""
|
||||
if self._blueprints:
|
||||
|
@@ -14,7 +14,7 @@ from homeassistant.util import yaml
|
||||
|
||||
from . import importer, models
|
||||
from .const import DOMAIN
|
||||
from .errors import FileAlreadyExists
|
||||
from .errors import FailedToLoad, FileAlreadyExists
|
||||
|
||||
|
||||
@callback
|
||||
@@ -81,6 +81,23 @@ async def ws_import_blueprint(
|
||||
)
|
||||
return
|
||||
|
||||
# Check it exists and if so, which automations are using it
|
||||
domain = imported_blueprint.blueprint.metadata["domain"]
|
||||
domain_blueprints: models.DomainBlueprints | None = hass.data.get(DOMAIN, {}).get(
|
||||
domain
|
||||
)
|
||||
if domain_blueprints is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_INVALID_FORMAT, "Unsupported domain"
|
||||
)
|
||||
return
|
||||
|
||||
suggested_path = f"{imported_blueprint.suggested_filename}.yaml"
|
||||
try:
|
||||
exists = bool(await domain_blueprints.async_get_blueprint(suggested_path))
|
||||
except FailedToLoad:
|
||||
exists = False
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
@@ -90,6 +107,7 @@ async def ws_import_blueprint(
|
||||
"metadata": imported_blueprint.blueprint.metadata,
|
||||
},
|
||||
"validation_errors": imported_blueprint.blueprint.validate(),
|
||||
"exists": exists,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -101,6 +119,7 @@ async def ws_import_blueprint(
|
||||
vol.Required("path"): cv.path,
|
||||
vol.Required("yaml"): cv.string,
|
||||
vol.Optional("source_url"): cv.url,
|
||||
vol.Optional("allow_override"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -130,8 +149,13 @@ async def ws_save_blueprint(
|
||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
||||
return
|
||||
|
||||
if not path.endswith(".yaml"):
|
||||
path = f"{path}.yaml"
|
||||
|
||||
try:
|
||||
await domain_blueprints[domain].async_add_blueprint(blueprint, path)
|
||||
overrides_existing = await domain_blueprints[domain].async_add_blueprint(
|
||||
blueprint, path, allow_override=msg.get("allow_override", False)
|
||||
)
|
||||
except FileAlreadyExists:
|
||||
connection.send_error(msg["id"], "already_exists", "File already exists")
|
||||
return
|
||||
@@ -141,6 +165,9 @@ async def ws_save_blueprint(
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"overrides_existing": overrides_existing,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
@@ -334,7 +334,7 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
local_name = prev_name
|
||||
|
||||
if service_uuids and service_uuids != prev_service_uuids:
|
||||
service_uuids = list(set(service_uuids + prev_service_uuids))
|
||||
service_uuids = list({*service_uuids, *prev_service_uuids})
|
||||
elif not service_uuids:
|
||||
service_uuids = prev_service_uuids
|
||||
|
||||
|
@@ -124,6 +124,7 @@ class BluetoothManager:
|
||||
"storage",
|
||||
"slot_manager",
|
||||
"_debug",
|
||||
"shutdown",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -165,6 +166,7 @@ class BluetoothManager:
|
||||
self.storage = storage
|
||||
self.slot_manager = slot_manager
|
||||
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
self.shutdown = False
|
||||
|
||||
@property
|
||||
def supports_passive_scan(self) -> bool:
|
||||
@@ -259,6 +261,7 @@ class BluetoothManager:
|
||||
def async_stop(self, event: Event) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
_LOGGER.debug("Stopping bluetooth manager")
|
||||
self.shutdown = True
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking = None
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"bleak-retry-connector==3.3.0",
|
||||
"bluetooth-adapters==0.16.1",
|
||||
"bluetooth-auto-recovery==1.2.3",
|
||||
"bluetooth-data-tools==1.14.0",
|
||||
"bluetooth-data-tools==1.16.0",
|
||||
"dbus-fast==2.14.0"
|
||||
]
|
||||
}
|
||||
|
@@ -270,6 +270,8 @@ class HaBleakClientWrapper(BleakClient):
|
||||
"""Connect to the specified GATT server."""
|
||||
assert models.MANAGER is not None
|
||||
manager = models.MANAGER
|
||||
if manager.shutdown:
|
||||
raise BleakError("Bluetooth is already shutdown")
|
||||
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
|
||||
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected==0.14.2"]
|
||||
"requirements": ["bimmer-connected[china]==0.14.5"]
|
||||
}
|
||||
|
@@ -44,7 +44,8 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = {
|
||||
translation_key="ac_limit",
|
||||
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
|
||||
dynamic_options=lambda v: [
|
||||
str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
|
||||
str(lim)
|
||||
for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
|
||||
],
|
||||
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
|
@@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action(
|
||||
Action.BREEZE_ON
|
||||
):
|
||||
raise ValueError(f"Invalid preset mode: {preset_mode}")
|
||||
await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
|
@@ -12,6 +12,9 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of your Bond hub."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.2.57"],
|
||||
"requirements": ["boschshcpy==0.2.75"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@@ -6,6 +6,9 @@
|
||||
"title": "SHC authentication parameters",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Bosch Smart Home Controller."
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"description": "Ensure that your TV is turned on before trying to set it up.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Sony Bravia TV to control."
|
||||
}
|
||||
},
|
||||
"authorize": {
|
||||
|
85
homeassistant/components/broadlink/climate.py
Normal file
85
homeassistant/components/broadlink/climate.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Support for Broadlink climate devices."""
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TEMPERATURE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PRECISION_HALVES, Platform, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, DOMAINS_AND_TYPES
|
||||
from .device import BroadlinkDevice
|
||||
from .entity import BroadlinkEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Broadlink climate entities."""
|
||||
device = hass.data[DOMAIN].devices[config_entry.entry_id]
|
||||
|
||||
if device.api.type in DOMAINS_AND_TYPES[Platform.CLIMATE]:
|
||||
async_add_entities([BroadlinkThermostat(device)])
|
||||
|
||||
|
||||
class BroadlinkThermostat(ClimateEntity, BroadlinkEntity):
|
||||
"""Representation of a Broadlink Hysen climate entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_target_temperature_step = PRECISION_HALVES
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(device)
|
||||
self._attr_unique_id = device.unique_id
|
||||
self._attr_hvac_mode = None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
await self._device.async_request(self._device.api.set_temp, temperature)
|
||||
self._attr_target_temperature = temperature
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _update_state(self, data: dict[str, Any]) -> None:
|
||||
"""Update data."""
|
||||
if data.get("power"):
|
||||
if data.get("auto_mode"):
|
||||
self._attr_hvac_mode = HVACMode.AUTO
|
||||
else:
|
||||
self._attr_hvac_mode = HVACMode.HEAT
|
||||
|
||||
if data.get("active"):
|
||||
self._attr_hvac_action = HVACAction.HEATING
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
else:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
|
||||
self._attr_current_temperature = data.get("room_temp")
|
||||
self._attr_target_temperature = data.get("thermostat_temp")
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self._device.async_request(self._device.api.set_power, 0)
|
||||
else:
|
||||
await self._device.async_request(self._device.api.set_power, 1)
|
||||
mode = 0 if hvac_mode == HVACMode.HEAT else 1
|
||||
await self._device.async_request(self._device.api.set_mode, mode, 0)
|
||||
|
||||
self._attr_hvac_mode = hvac_mode
|
||||
self.async_write_ha_state()
|
@@ -4,6 +4,7 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "broadlink"
|
||||
|
||||
DOMAINS_AND_TYPES = {
|
||||
Platform.CLIMATE: {"HYS"},
|
||||
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
|
||||
Platform.SENSOR: {
|
||||
"A1",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "broadlink",
|
||||
"name": "Broadlink",
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
@@ -30,6 +30,9 @@
|
||||
},
|
||||
{
|
||||
"macaddress": "EC0BAE*"
|
||||
},
|
||||
{
|
||||
"macaddress": "780F77*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/broadlink",
|
||||
|
@@ -3,10 +3,13 @@
|
||||
"flow_title": "{name} ({model} at {host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the device",
|
||||
"description": "Connect to the device",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"timeout": "Timeout"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Broadlink device."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
|
@@ -16,6 +16,7 @@ def get_update_manager(device):
|
||||
update_managers = {
|
||||
"A1": BroadlinkA1UpdateManager,
|
||||
"BG1": BroadlinkBG1UpdateManager,
|
||||
"HYS": BroadlinkThermostatUpdateManager,
|
||||
"LB1": BroadlinkLB1UpdateManager,
|
||||
"LB2": BroadlinkLB1UpdateManager,
|
||||
"MP1": BroadlinkMP1UpdateManager,
|
||||
@@ -184,3 +185,11 @@ class BroadlinkLB1UpdateManager(BroadlinkUpdateManager):
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.get_state)
|
||||
|
||||
|
||||
class BroadlinkThermostatUpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for thermostats with Broadlink DNA."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.get_full_status)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user