forked from home-assistant/core
Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 083766776d | |||
| 60b0f0dc53 | |||
| c3921f2112 | |||
| aa619c5594 | |||
| 0677a256ec | |||
| 97ffbf5aad | |||
| 006b2da14e | |||
| 56b4ddc6b4 | |||
| 2c0c0b9e21 | |||
| 9e312f2063 | |||
| bbeecb40ae | |||
| 48c9361c01 | |||
| d686b877b1 | |||
| d2d01b337d | |||
| b0bfe71b9b | |||
| 38f3fa0210 | |||
| 86ae70780c | |||
| 65e16b4814 | |||
| 70966c2b63 | |||
| ba7f36328d | |||
| 511ecf98d5 | |||
| b5831344a0 | |||
| 984eba809c | |||
| f778033bd8 | |||
| a8f2204f4f | |||
| 4c56cbe8c8 | |||
| 71d35a03e1 | |||
| c8fd48523f | |||
| fbd3bf7a98 | |||
| a0356f587e | |||
| 199a4b725b | |||
| 505df84783 | |||
| baa9473383 | |||
| ba5d23290a | |||
| adda02b6b1 | |||
| 1f59bd9f92 | |||
| b61678d39c | |||
| b23297bb7e | |||
| f56c38d69b | |||
| c2b24dd355 | |||
| c4c8e74a8a | |||
| c4029300c2 | |||
| 52320844fc | |||
| b4e20409de | |||
| 4ecc6555bf | |||
| 892c32c8b7 | |||
| bad305dcbf | |||
| 7266a16295 | |||
| 416a2de179 | |||
| 349ea35dc3 | |||
| 0fb1fbf0d1 | |||
| 643fd34478 | |||
| 186c9aa33b | |||
| af51241c0d | |||
| eaee8d5b78 | |||
| 638434c103 | |||
| 3a44098ddf | |||
| 1e1c3506fe | |||
| b557e9e826 | |||
| da0d1b71ce | |||
| 4d96ed4c68 | |||
| 1bc63a61be | |||
| 5c35ccb9ca | |||
| a1ecefee21 | |||
| b5d7eba4f6 | |||
| 4b111008df | |||
| fb5afff9d5 | |||
| 38a1c97a51 | |||
| b26e4d672f | |||
| daa5268cf2 | |||
| 9da3f98c23 | |||
| 8fd691be69 | |||
| 7fc0e36b2f | |||
| 482bed522f | |||
| 7788685340 | |||
| af1af6f391 | |||
| d5c2e6ec35 | |||
| d8382c6de2 | |||
| c4cfff4b3f | |||
| cfe0c95c97 | |||
| 50c1bf8bb0 | |||
| cc3d059783 | |||
| 4aa86a574f | |||
| 3a8039cbc0 | |||
| e4f9f6447f | |||
| 14482ff6da | |||
| be8f14167f | |||
| 27032c1780 | |||
| 61a722218a | |||
| 3137c27e56 | |||
| 7b35c3036e | |||
| 8e03f3a045 | |||
| 8f26cff65a | |||
| 00533bae4b | |||
| 8255728f53 | |||
| 470335e27a | |||
| 56887747a6 | |||
| 1dcae0c0a6 | |||
| 8759a6a14d | |||
| 5d072d1030 | |||
| 78517f75e8 | |||
| 42ed7fbb0d | |||
| 96be3e2505 | |||
| 2fa3b9070c | |||
| d827c53a85 | |||
| 436ac72b82 | |||
| 7c15075231 | |||
| 8e3ad2d1f3 | |||
| 733bbf9cd1 | |||
| 822660732b | |||
| d6bd4312ab | |||
| 491bde181c | |||
| fdce524811 | |||
| cf10549df4 | |||
| fd01e22ca4 | |||
| 334359bb0a | |||
| eda1656e75 | |||
| 6cea6be4a7 | |||
| 6ecc5c19a2 | |||
| c321bd70e1 | |||
| 851600630c | |||
| e3896d1f60 | |||
| c71cf272c8 | |||
| d12c6f89d2 | |||
| 5965d8d503 | |||
| 94f458ff98 | |||
| c07a9e9d59 | |||
| b9db9eeab2 | |||
| f34b449f61 | |||
| fc24843274 | |||
| 22b6239304 | |||
| aa8fe99113 | |||
| 7c223db1d5 | |||
| 0c18b2e7ff | |||
| d68ee8dcea | |||
| 671aaa7e95 | |||
| faefe624f6 | |||
| f93259a2f1 | |||
| 606524f9e7 | |||
| cd89db9bb6 | |||
| f760c13e8f | |||
| 687cd32142 | |||
| fb27297df9 | |||
| 3e350bdc90 | |||
| 0b14f0a379 | |||
| 3206979488 | |||
| 4c27bfbf7f | |||
| 7c4fd9473c | |||
| 633c904852 | |||
| 5300eddf33 | |||
| 9f558d13e6 | |||
| 9ae59e5ea0 | |||
| 1b1c1c2a55 | |||
| df4bd721b5 | |||
| baa876d4d9 | |||
| b99dceab74 | |||
| 114e254aa6 | |||
| fbfd8c48aa | |||
| d40e3145fe | |||
| f4a16c8dc9 | |||
| 2ce6bd2378 | |||
| 9334099bed | |||
| 077edb08f6 | |||
| 72d5146a3e | |||
| fa14321aa1 | |||
| 8f679fcbf3 | |||
| 78cf7dc873 | |||
| 9fff3a13a5 | |||
| 99f43400bf | |||
| 77b464f2bd | |||
| 07e251d488 | |||
| 659d135fca | |||
| 24414369d7 | |||
| 92c1fb77e9 | |||
| ae1f53775f | |||
| bd6b5568eb | |||
| 5f2964d3e8 | |||
| c6865d0862 | |||
| ef8fc3913e | |||
| 56667ec2bc | |||
| fa21613951 | |||
| f735d12a66 | |||
| 2f7a396778 | |||
| 12336f5c15 | |||
| 1661304f10 | |||
| 68162e1a27 | |||
| 95a25c72dc | |||
| 30772da0e1 | |||
| ef84a8869e | |||
| d3879a36d1 | |||
| 93afc9458a | |||
| 5cd8e4ab7e | |||
| 994c2ebca1 | |||
| 81f5068354 | |||
| 3e60d7aa11 | |||
| 30aa3a26ad | |||
| 9da5dd0090 | |||
| 65f007ace7 | |||
| 2a8feda691 | |||
| 36b7e8569e | |||
| 221f961574 | |||
| 7210cc1da6 | |||
| 5fa23b1785 | |||
| 2cab9f7fe9 | |||
| 3bfcb1ebdd | |||
| c1eb5f8b74 | |||
| 582b7eab66 | |||
| 26281662b5 | |||
| ac39bf991f | |||
| 0a9e20615e | |||
| 8cafa1bcdf | |||
| 66ddf44399 | |||
| 933ae143b3 | |||
| 8c2e63807c | |||
| 460363c4ba | |||
| 29a17edaa5 | |||
| ed161d3d49 | |||
| 7868ffac35 | |||
| 910fb0930e | |||
| cb742a677c | |||
| 28c24e5fef | |||
| 50577883dc | |||
| 20f9b9e412 | |||
| 1d05a917f9 | |||
| 240bd6c3bf | |||
| c01bb44757 | |||
| 5e93394ae7 | |||
| a8b55a16fd | |||
| d7fb245213 | |||
| 6467c8d611 | |||
| c47b37af4f | |||
| a5bacf5652 | |||
| 6589216ed3 | |||
| b6dc410464 | |||
| 928ff7c78c | |||
| c9335598db | |||
| 32babd3958 | |||
| 7f405686d1 | |||
| 5bd736029f | |||
| 397198c6d0 | |||
| 54188b4128 | |||
| f3da9de744 | |||
| aeb95c4509 | |||
| f394dfb8d0 | |||
| 6781a76de2 | |||
| 69a9aa4594 | |||
| afa02dcce9 | |||
| febb382030 | |||
| 1906155c18 | |||
| ffabd5d7db | |||
| 9e2360791d | |||
| 19cbc1b258 | |||
| df2ea1e875 | |||
| 74fa30e59d | |||
| 6833af6286 | |||
| 4940968cd5 | |||
| a9975071c3 | |||
| cc4340b80c | |||
| 252f05e0f7 | |||
| f5e0382123 | |||
| 600c6a0dcb | |||
| df60e59a95 | |||
| cf90e77e57 | |||
| 3e0bd44d2a | |||
| 7bb93d4f3e | |||
| 4dfc11a140 | |||
| 7eeebf198b | |||
| 175ffe29f6 | |||
| ff9937f942 | |||
| ef452427e3 | |||
| a04970bd54 | |||
| 149aebb0bc | |||
| c36fc70ab4 | |||
| 681fe3485d | |||
| 34680becaa | |||
| 354f4491c8 | |||
| c4fd1cfc8f | |||
| a4e9e4b23b | |||
| eac7794741 | |||
| 1cb9690001 | |||
| 1101e7ef64 | |||
| 3b6128d590 | |||
| 7f4fca63ed | |||
| 4b59ef4733 | |||
| 3d39f6ce88 | |||
| 5f810d908f | |||
| c7cfd56b72 | |||
| ada6b7875c | |||
| 2b20b2a80b | |||
| 5825e8fee8 | |||
| 70488ffd15 | |||
| 2900fa733d | |||
| 7d61dd13d9 | |||
| af8131e68f | |||
| c049129147 | |||
| 2dce876a86 |
@@ -14,6 +14,7 @@ core: &core
|
||||
base_platforms: &base_platforms
|
||||
- homeassistant/components/air_quality/**
|
||||
- homeassistant/components/alarm_control_panel/**
|
||||
- homeassistant/components/assist_satellite/**
|
||||
- homeassistant/components/binary_sensor/**
|
||||
- homeassistant/components/button/**
|
||||
- homeassistant/components/calendar/**
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -453,7 +453,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
+45
-34
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 10
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 8
|
||||
HA_SHORT_VERSION: "2024.9"
|
||||
HA_SHORT_VERSION: "2024.10"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
ALL_PYTHON_VERSIONS: "['3.12']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -279,7 +279,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -319,7 +319,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -429,17 +429,28 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
|
||||
|
||||
lint-hadolint:
|
||||
name: Check ${{ matrix.file }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- info
|
||||
- pre-commit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
file:
|
||||
- Dockerfile
|
||||
- Dockerfile.dev
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
- name: Check Dockerfile
|
||||
uses: docker://hadolint/hadolint:v1.18.2
|
||||
- name: Check ${{ matrix.file }}
|
||||
uses: docker://hadolint/hadolint:v2.12.0
|
||||
with:
|
||||
args: hadolint Dockerfile
|
||||
- name: Check Dockerfile.dev
|
||||
uses: docker://hadolint/hadolint:v1.18.2
|
||||
with:
|
||||
args: hadolint Dockerfile.dev
|
||||
args: hadolint ${{ matrix.file }}
|
||||
|
||||
base:
|
||||
name: Prepare dependencies
|
||||
@@ -454,7 +465,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -538,7 +549,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -571,7 +582,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -605,7 +616,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -623,7 +634,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pip-licenses --format=json --output-file=licenses.json
|
||||
- name: Upload licenses
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: licenses
|
||||
path: licenses.json
|
||||
@@ -648,7 +659,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -695,7 +706,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -740,7 +751,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -815,7 +826,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -833,7 +844,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
path: pytest_buckets.txt
|
||||
@@ -879,7 +890,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -934,14 +945,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -999,7 +1010,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -1060,7 +1071,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1068,7 +1079,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1125,7 +1136,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -1187,7 +1198,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1195,7 +1206,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1271,7 +1282,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -1329,14 +1340,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.26.5
|
||||
uses: github/codeql-action/init@v3.26.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.26.5
|
||||
uses: github/codeql-action/analyze@v3.26.6
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.1
|
||||
uses: actions/setup-python@v5.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -82,14 +82,15 @@ jobs:
|
||||
) > .env_file
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -101,7 +102,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
@@ -139,7 +140,7 @@ jobs:
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm"
|
||||
skip-binary: aiohttp
|
||||
skip-binary: aiohttp;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements.txt"
|
||||
@@ -211,7 +212,7 @@ jobs:
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_old-cython.txt"
|
||||
@@ -226,7 +227,7 @@ jobs:
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
@@ -240,7 +241,7 @@ jobs:
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtab"
|
||||
@@ -254,7 +255,7 @@ jobs:
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtac"
|
||||
|
||||
@@ -95,6 +95,7 @@ homeassistant.components.aruba.*
|
||||
homeassistant.components.arwn.*
|
||||
homeassistant.components.aseko_pool_live.*
|
||||
homeassistant.components.assist_pipeline.*
|
||||
homeassistant.components.assist_satellite.*
|
||||
homeassistant.components.asuswrt.*
|
||||
homeassistant.components.autarco.*
|
||||
homeassistant.components.auth.*
|
||||
@@ -110,6 +111,7 @@ homeassistant.components.bitcoin.*
|
||||
homeassistant.components.blockchain.*
|
||||
homeassistant.components.blue_current.*
|
||||
homeassistant.components.blueprint.*
|
||||
homeassistant.components.bluesound.*
|
||||
homeassistant.components.bluetooth.*
|
||||
homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bluetooth_tracker.*
|
||||
@@ -139,6 +141,7 @@ homeassistant.components.cpuspeed.*
|
||||
homeassistant.components.crownstone.*
|
||||
homeassistant.components.date.*
|
||||
homeassistant.components.datetime.*
|
||||
homeassistant.components.deako.*
|
||||
homeassistant.components.deconz.*
|
||||
homeassistant.components.default_config.*
|
||||
homeassistant.components.demo.*
|
||||
@@ -208,6 +211,8 @@ homeassistant.components.glances.*
|
||||
homeassistant.components.goalzero.*
|
||||
homeassistant.components.google.*
|
||||
homeassistant.components.google_assistant_sdk.*
|
||||
homeassistant.components.google_cloud.*
|
||||
homeassistant.components.google_photos.*
|
||||
homeassistant.components.google_sheets.*
|
||||
homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
@@ -278,6 +283,7 @@ homeassistant.components.lawn_mower.*
|
||||
homeassistant.components.lcn.*
|
||||
homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
@@ -336,6 +342,7 @@ homeassistant.components.nut.*
|
||||
homeassistant.components.onboarding.*
|
||||
homeassistant.components.oncue.*
|
||||
homeassistant.components.onewire.*
|
||||
homeassistant.components.onkyo.*
|
||||
homeassistant.components.open_meteo.*
|
||||
homeassistant.components.openexchangerates.*
|
||||
homeassistant.components.opensky.*
|
||||
@@ -395,6 +402,7 @@ homeassistant.components.select.*
|
||||
homeassistant.components.sensibo.*
|
||||
homeassistant.components.sensirion_ble.*
|
||||
homeassistant.components.sensor.*
|
||||
homeassistant.components.sensoterra.*
|
||||
homeassistant.components.senz.*
|
||||
homeassistant.components.sfr_box.*
|
||||
homeassistant.components.shelly.*
|
||||
@@ -407,9 +415,11 @@ homeassistant.components.slack.*
|
||||
homeassistant.components.sleepiq.*
|
||||
homeassistant.components.smhi.*
|
||||
homeassistant.components.snooz.*
|
||||
homeassistant.components.solarlog.*
|
||||
homeassistant.components.sonarr.*
|
||||
homeassistant.components.speedtestdotnet.*
|
||||
homeassistant.components.sql.*
|
||||
homeassistant.components.squeezebox.*
|
||||
homeassistant.components.ssdp.*
|
||||
homeassistant.components.starlink.*
|
||||
homeassistant.components.statistics.*
|
||||
|
||||
+22
-5
@@ -143,6 +143,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/aseko_pool_live/ @milanmeu
|
||||
/homeassistant/components/assist_pipeline/ @balloob @synesthesiam
|
||||
/tests/components/assist_pipeline/ @balloob @synesthesiam
|
||||
/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam
|
||||
/tests/components/assist_satellite/ @home-assistant/core @synesthesiam
|
||||
/homeassistant/components/asuswrt/ @kennedyshead @ollo69
|
||||
/tests/components/asuswrt/ @kennedyshead @ollo69
|
||||
/homeassistant/components/atag/ @MatsNL
|
||||
@@ -228,8 +230,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/bsblan/ @liudger
|
||||
/tests/components/bsblan/ @liudger
|
||||
/homeassistant/components/bt_smarthub/ @typhoon2099
|
||||
/homeassistant/components/bthome/ @Ernst79
|
||||
/tests/components/bthome/ @Ernst79
|
||||
/homeassistant/components/bthome/ @Ernst79 @thecode
|
||||
/tests/components/bthome/ @Ernst79 @thecode
|
||||
/homeassistant/components/buienradar/ @mjj4791 @ties @Robbie1221
|
||||
/tests/components/buienradar/ @mjj4791 @ties @Robbie1221
|
||||
/homeassistant/components/button/ @home-assistant/core
|
||||
@@ -294,6 +296,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/date/ @home-assistant/core
|
||||
/homeassistant/components/datetime/ @home-assistant/core
|
||||
/tests/components/datetime/ @home-assistant/core
|
||||
/homeassistant/components/deako/ @sebirdman @balake @deakolights
|
||||
/tests/components/deako/ @sebirdman @balake @deakolights
|
||||
/homeassistant/components/debugpy/ @frenck
|
||||
/tests/components/debugpy/ @frenck
|
||||
/homeassistant/components/deconz/ @Kane610
|
||||
@@ -547,11 +551,14 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/google_assistant/ @home-assistant/cloud
|
||||
/homeassistant/components/google_assistant_sdk/ @tronikos
|
||||
/tests/components/google_assistant_sdk/ @tronikos
|
||||
/homeassistant/components/google_cloud/ @lufton
|
||||
/homeassistant/components/google_cloud/ @lufton @tronikos
|
||||
/tests/components/google_cloud/ @lufton @tronikos
|
||||
/homeassistant/components/google_generative_ai_conversation/ @tronikos
|
||||
/tests/components/google_generative_ai_conversation/ @tronikos
|
||||
/homeassistant/components/google_mail/ @tkdrob
|
||||
/tests/components/google_mail/ @tkdrob
|
||||
/homeassistant/components/google_photos/ @allenporter
|
||||
/tests/components/google_photos/ @allenporter
|
||||
/homeassistant/components/google_sheets/ @tkdrob
|
||||
/tests/components/google_sheets/ @tkdrob
|
||||
/homeassistant/components/google_tasks/ @allenporter
|
||||
@@ -629,6 +636,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/html5/ @alexyao2015
|
||||
/tests/components/html5/ @alexyao2015
|
||||
/homeassistant/components/http/ @home-assistant/core
|
||||
/tests/components/http/ @home-assistant/core
|
||||
/homeassistant/components/huawei_lte/ @scop @fphammerle
|
||||
@@ -707,8 +716,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ios/ @robbiet480
|
||||
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
|
||||
/tests/components/iotawatt/ @gtdiehl @jyavenard
|
||||
/homeassistant/components/iotty/ @pburgio
|
||||
/tests/components/iotty/ @pburgio
|
||||
/homeassistant/components/iotty/ @pburgio @shapournemati-iotty
|
||||
/tests/components/iotty/ @pburgio @shapournemati-iotty
|
||||
/homeassistant/components/iperf3/ @rohankapoorcom
|
||||
/homeassistant/components/ipma/ @dgomes
|
||||
/tests/components/ipma/ @dgomes
|
||||
@@ -721,6 +730,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/iron_os/ @tr4nt0r
|
||||
/homeassistant/components/isal/ @bdraco
|
||||
/tests/components/isal/ @bdraco
|
||||
/homeassistant/components/iskra/ @iskramis
|
||||
/tests/components/iskra/ @iskramis
|
||||
/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
|
||||
/tests/components/islamic_prayer_times/ @engrbm87 @cpfair
|
||||
/homeassistant/components/israel_rail/ @shaiu
|
||||
@@ -797,8 +808,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/leaone/ @bdraco
|
||||
/homeassistant/components/led_ble/ @bdraco
|
||||
/tests/components/led_ble/ @bdraco
|
||||
/homeassistant/components/lektrico/ @lektrico
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/lifx/ @Djelibeybi
|
||||
@@ -1275,6 +1290,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sensorpro/ @bdraco
|
||||
/homeassistant/components/sensorpush/ @bdraco
|
||||
/tests/components/sensorpush/ @bdraco
|
||||
/homeassistant/components/sensoterra/ @markruys
|
||||
/tests/components/sensoterra/ @markruys
|
||||
/homeassistant/components/sentry/ @dcramer @frenck
|
||||
/tests/components/sentry/ @dcramer @frenck
|
||||
/homeassistant/components/senz/ @milanmeu
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"google_generative_ai_conversation",
|
||||
"google_mail",
|
||||
"google_maps",
|
||||
"google_photos",
|
||||
"google_pubsub",
|
||||
"google_sheets",
|
||||
"google_tasks",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "lg",
|
||||
"name": "LG",
|
||||
"integrations": ["lg_netcast", "lg_soundbar", "webostv"]
|
||||
"integrations": ["lg_netcast", "lg_thinq", "lg_soundbar", "webostv"]
|
||||
}
|
||||
|
||||
@@ -6,52 +6,3 @@ Component design guidelines:
|
||||
format "<DOMAIN>.<OBJECT_ID>".
|
||||
- Each component should publish services only under its own domain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers.frame import report
|
||||
from homeassistant.helpers.group import expand_entity_ids
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool:
|
||||
"""Load up the module to call the is_on method.
|
||||
|
||||
If there is no entity id given we will check all.
|
||||
"""
|
||||
report(
|
||||
(
|
||||
"uses homeassistant.components.is_on."
|
||||
" This is deprecated and will stop working in Home Assistant 2024.9, it"
|
||||
" should be updated to use the function of the platform directly."
|
||||
),
|
||||
error_if_core=True,
|
||||
)
|
||||
|
||||
if entity_id:
|
||||
entity_ids = expand_entity_ids(hass, [entity_id])
|
||||
else:
|
||||
entity_ids = hass.states.entity_ids()
|
||||
|
||||
for ent_id in entity_ids:
|
||||
domain = split_entity_id(ent_id)[0]
|
||||
|
||||
try:
|
||||
component = getattr(hass.components, domain)
|
||||
|
||||
except ImportError:
|
||||
_LOGGER.error("Failed to call %s.is_on: component not found", domain)
|
||||
continue
|
||||
|
||||
if not hasattr(component, "is_on"):
|
||||
_LOGGER.warning("Integration %s has no is_on method", domain)
|
||||
continue
|
||||
|
||||
if component.is_on(ent_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.const import (
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
UnitOfLength,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
@@ -279,6 +280,15 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
translation_key="realfeel_temperature_shade",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="RelativeHumidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data: cast(int, data),
|
||||
translation_key="humidity",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Precipitation",
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
@@ -288,6 +298,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
attr_fn=lambda data: {"type": data["PrecipitationType"]},
|
||||
translation_key="precipitation",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
translation_key="pressure",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="PressureTendency",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
@@ -295,9 +315,19 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
value_fn=lambda data: cast(str, data["LocalizedText"]).lower(),
|
||||
translation_key="pressure_tendency",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
|
||||
translation_key="temperature",
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="UVIndex",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UV_INDEX,
|
||||
value_fn=lambda data: cast(int, data),
|
||||
attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]},
|
||||
@@ -324,6 +354,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
|
||||
|
||||
@@ -29,18 +29,40 @@ DATA_ADS = "data_ads"
|
||||
# Supported Types
|
||||
ADSTYPE_BOOL = "bool"
|
||||
ADSTYPE_BYTE = "byte"
|
||||
ADSTYPE_DINT = "dint"
|
||||
ADSTYPE_INT = "int"
|
||||
ADSTYPE_UDINT = "udint"
|
||||
ADSTYPE_UINT = "uint"
|
||||
ADSTYPE_SINT = "sint"
|
||||
ADSTYPE_USINT = "usint"
|
||||
ADSTYPE_DINT = "dint"
|
||||
ADSTYPE_UDINT = "udint"
|
||||
ADSTYPE_WORD = "word"
|
||||
ADSTYPE_DWORD = "dword"
|
||||
ADSTYPE_LREAL = "lreal"
|
||||
ADSTYPE_REAL = "real"
|
||||
ADSTYPE_STRING = "string"
|
||||
ADSTYPE_TIME = "time"
|
||||
ADSTYPE_DATE = "date"
|
||||
ADSTYPE_DATE_AND_TIME = "dt"
|
||||
ADSTYPE_TOD = "tod"
|
||||
|
||||
ADS_TYPEMAP = {
|
||||
ADSTYPE_BOOL: pyads.PLCTYPE_BOOL,
|
||||
ADSTYPE_BYTE: pyads.PLCTYPE_BYTE,
|
||||
ADSTYPE_DINT: pyads.PLCTYPE_DINT,
|
||||
ADSTYPE_INT: pyads.PLCTYPE_INT,
|
||||
ADSTYPE_UDINT: pyads.PLCTYPE_UDINT,
|
||||
ADSTYPE_UINT: pyads.PLCTYPE_UINT,
|
||||
ADSTYPE_SINT: pyads.PLCTYPE_SINT,
|
||||
ADSTYPE_USINT: pyads.PLCTYPE_USINT,
|
||||
ADSTYPE_DINT: pyads.PLCTYPE_DINT,
|
||||
ADSTYPE_UDINT: pyads.PLCTYPE_UDINT,
|
||||
ADSTYPE_WORD: pyads.PLCTYPE_WORD,
|
||||
ADSTYPE_DWORD: pyads.PLCTYPE_DWORD,
|
||||
ADSTYPE_REAL: pyads.PLCTYPE_REAL,
|
||||
ADSTYPE_LREAL: pyads.PLCTYPE_LREAL,
|
||||
ADSTYPE_STRING: pyads.PLCTYPE_STRING,
|
||||
ADSTYPE_TIME: pyads.PLCTYPE_TIME,
|
||||
ADSTYPE_DATE: pyads.PLCTYPE_DATE,
|
||||
ADSTYPE_DATE_AND_TIME: pyads.PLCTYPE_DT,
|
||||
ADSTYPE_TOD: pyads.PLCTYPE_TOD,
|
||||
}
|
||||
|
||||
CONF_ADS_FACTOR = "factor"
|
||||
@@ -75,12 +97,23 @@ SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADS_TYPE): vol.In(
|
||||
[
|
||||
ADSTYPE_BOOL,
|
||||
ADSTYPE_BYTE,
|
||||
ADSTYPE_INT,
|
||||
ADSTYPE_UINT,
|
||||
ADSTYPE_BYTE,
|
||||
ADSTYPE_BOOL,
|
||||
ADSTYPE_SINT,
|
||||
ADSTYPE_USINT,
|
||||
ADSTYPE_DINT,
|
||||
ADSTYPE_UDINT,
|
||||
ADSTYPE_WORD,
|
||||
ADSTYPE_DWORD,
|
||||
ADSTYPE_REAL,
|
||||
ADSTYPE_LREAL,
|
||||
ADSTYPE_STRING,
|
||||
ADSTYPE_TIME,
|
||||
ADSTYPE_DATE,
|
||||
ADSTYPE_DATE_AND_TIME,
|
||||
ADSTYPE_TOD,
|
||||
]
|
||||
),
|
||||
vol.Required(CONF_ADS_VALUE): vol.Coerce(int),
|
||||
@@ -222,37 +255,53 @@ class AdsHub:
|
||||
def _device_notification_callback(self, notification, name):
|
||||
"""Handle device notifications."""
|
||||
contents = notification.contents
|
||||
|
||||
hnotify = int(contents.hNotification)
|
||||
_LOGGER.debug("Received notification %d", hnotify)
|
||||
|
||||
# get dynamically sized data array
|
||||
# Get dynamically sized data array
|
||||
data_size = contents.cbSampleSize
|
||||
data = (ctypes.c_ubyte * data_size).from_address(
|
||||
data_address = (
|
||||
ctypes.addressof(contents)
|
||||
+ pyads.structs.SAdsNotificationHeader.data.offset
|
||||
)
|
||||
data = (ctypes.c_ubyte * data_size).from_address(data_address)
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
notification_item = self._notification_items[hnotify]
|
||||
except KeyError:
|
||||
# Acquire notification item
|
||||
with self._lock:
|
||||
notification_item = self._notification_items.get(hnotify)
|
||||
|
||||
if not notification_item:
|
||||
_LOGGER.error("Unknown device notification handle: %d", hnotify)
|
||||
return
|
||||
|
||||
# Parse data to desired datatype
|
||||
if notification_item.plc_datatype == pyads.PLCTYPE_BOOL:
|
||||
# Data parsing based on PLC data type
|
||||
plc_datatype = notification_item.plc_datatype
|
||||
unpack_formats = {
|
||||
pyads.PLCTYPE_BYTE: "<b",
|
||||
pyads.PLCTYPE_INT: "<h",
|
||||
pyads.PLCTYPE_UINT: "<H",
|
||||
pyads.PLCTYPE_SINT: "<b",
|
||||
pyads.PLCTYPE_USINT: "<B",
|
||||
pyads.PLCTYPE_DINT: "<i",
|
||||
pyads.PLCTYPE_UDINT: "<I",
|
||||
pyads.PLCTYPE_WORD: "<H",
|
||||
pyads.PLCTYPE_DWORD: "<I",
|
||||
pyads.PLCTYPE_LREAL: "<d",
|
||||
pyads.PLCTYPE_REAL: "<f",
|
||||
pyads.PLCTYPE_TOD: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DATE: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_DT: "<i", # Treat as DINT
|
||||
pyads.PLCTYPE_TIME: "<i", # Treat as DINT
|
||||
}
|
||||
|
||||
if plc_datatype == pyads.PLCTYPE_BOOL:
|
||||
value = bool(struct.unpack("<?", bytearray(data))[0])
|
||||
elif notification_item.plc_datatype == pyads.PLCTYPE_INT:
|
||||
value = struct.unpack("<h", bytearray(data))[0]
|
||||
elif notification_item.plc_datatype == pyads.PLCTYPE_BYTE:
|
||||
value = struct.unpack("<B", bytearray(data))[0]
|
||||
elif notification_item.plc_datatype == pyads.PLCTYPE_UINT:
|
||||
value = struct.unpack("<H", bytearray(data))[0]
|
||||
elif notification_item.plc_datatype == pyads.PLCTYPE_DINT:
|
||||
value = struct.unpack("<i", bytearray(data))[0]
|
||||
elif notification_item.plc_datatype == pyads.PLCTYPE_UDINT:
|
||||
value = struct.unpack("<I", bytearray(data))[0]
|
||||
elif plc_datatype == pyads.PLCTYPE_STRING:
|
||||
value = (
|
||||
bytearray(data).split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
||||
)
|
||||
elif plc_datatype in unpack_formats:
|
||||
value = struct.unpack(unpack_formats[plc_datatype], bytearray(data))[0]
|
||||
else:
|
||||
value = bytearray(data)
|
||||
_LOGGER.warning("No callback available for this datatype")
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from airgradient import AirGradientClient, get_model_name
|
||||
from airgradient import AirGradientClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
|
||||
from .coordinator import AirGradientCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
@@ -25,15 +21,7 @@ PLATFORMS: list[Platform] = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirGradientData:
|
||||
"""AirGradient data class."""
|
||||
|
||||
measurement: AirGradientMeasurementCoordinator
|
||||
config: AirGradientConfigCoordinator
|
||||
|
||||
|
||||
type AirGradientConfigEntry = ConfigEntry[AirGradientData]
|
||||
type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
|
||||
@@ -43,27 +31,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry)
|
||||
entry.data[CONF_HOST], session=async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
measurement_coordinator = AirGradientMeasurementCoordinator(hass, client)
|
||||
config_coordinator = AirGradientConfigCoordinator(hass, client)
|
||||
coordinator = AirGradientCoordinator(hass, client)
|
||||
|
||||
await measurement_coordinator.async_config_entry_first_refresh()
|
||||
await config_coordinator.async_config_entry_first_refresh()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, measurement_coordinator.serial_number)},
|
||||
manufacturer="AirGradient",
|
||||
model=get_model_name(measurement_coordinator.data.model),
|
||||
model_id=measurement_coordinator.data.model,
|
||||
serial_number=measurement_coordinator.data.serial_number,
|
||||
sw_version=measurement_coordinator.data.firmware_version,
|
||||
)
|
||||
|
||||
entry.runtime_data = AirGradientData(
|
||||
measurement=measurement_coordinator,
|
||||
config=config_coordinator,
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DOMAIN, AirGradientConfigEntry
|
||||
from .coordinator import AirGradientConfigCoordinator
|
||||
from . import AirGradientConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
|
||||
@@ -47,8 +48,8 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AirGradient button entities based on a config entry."""
|
||||
model = entry.runtime_data.measurement.data.model
|
||||
coordinator = entry.runtime_data.config
|
||||
coordinator = entry.runtime_data
|
||||
model = coordinator.data.measures.model
|
||||
|
||||
added_entities = False
|
||||
|
||||
@@ -57,7 +58,7 @@ async def async_setup_entry(
|
||||
nonlocal added_entities
|
||||
|
||||
if (
|
||||
coordinator.data.configuration_control is ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control is ConfigurationControl.LOCAL
|
||||
and not added_entities
|
||||
):
|
||||
entities = [AirGradientButton(coordinator, CO2_CALIBRATION)]
|
||||
@@ -67,7 +68,8 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
added_entities = True
|
||||
elif (
|
||||
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control
|
||||
is not ConfigurationControl.LOCAL
|
||||
and added_entities
|
||||
):
|
||||
entity_registry = er.async_get(hass)
|
||||
@@ -87,11 +89,10 @@ class AirGradientButton(AirGradientEntity, ButtonEntity):
|
||||
"""Defines an AirGradient button."""
|
||||
|
||||
entity_description: AirGradientButtonEntityDescription
|
||||
coordinator: AirGradientConfigCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientConfigCoordinator,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: AirGradientButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize airgradient button."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -16,7 +17,15 @@ if TYPE_CHECKING:
|
||||
from . import AirGradientConfigEntry
|
||||
|
||||
|
||||
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
@dataclass
|
||||
class AirGradientData:
|
||||
"""Class for AirGradient data."""
|
||||
|
||||
measures: Measures
|
||||
config: Config
|
||||
|
||||
|
||||
class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
config_entry: AirGradientConfigEntry
|
||||
@@ -33,25 +42,11 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
assert self.config_entry.unique_id
|
||||
self.serial_number = self.config_entry.unique_id
|
||||
|
||||
async def _async_update_data(self) -> _DataT:
|
||||
async def _async_update_data(self) -> AirGradientData:
|
||||
try:
|
||||
return await self._update_data()
|
||||
measures = await self.client.get_current_measures()
|
||||
config = await self.client.get_config()
|
||||
except AirGradientError as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
async def _update_data(self) -> _DataT:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
async def _update_data(self) -> Measures:
|
||||
return await self.client.get_current_measures()
|
||||
|
||||
|
||||
class AirGradientConfigCoordinator(AirGradientCoordinator[Config]):
|
||||
"""Class to manage fetching AirGradient data."""
|
||||
|
||||
async def _update_data(self) -> Config:
|
||||
return await self.client.get_config()
|
||||
else:
|
||||
return AirGradientData(measures, config)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Base class for AirGradient entities."""
|
||||
|
||||
from airgradient import get_model_name
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -15,6 +17,12 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
|
||||
def __init__(self, coordinator: AirGradientCoordinator) -> None:
|
||||
"""Initialize airgradient entity."""
|
||||
super().__init__(coordinator)
|
||||
measures = coordinator.data.measures
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.serial_number)},
|
||||
manufacturer="AirGradient",
|
||||
model=get_model_name(measures.model),
|
||||
model_id=measures.model,
|
||||
serial_number=coordinator.serial_number,
|
||||
sw_version=measures.firmware_version,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AirGradientConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirGradientConfigCoordinator
|
||||
from .coordinator import AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AirGradient number entities based on a config entry."""
|
||||
|
||||
model = entry.runtime_data.measurement.data.model
|
||||
coordinator = entry.runtime_data.config
|
||||
coordinator = entry.runtime_data
|
||||
model = coordinator.data.measures.model
|
||||
|
||||
added_entities = False
|
||||
|
||||
@@ -72,7 +72,7 @@ async def async_setup_entry(
|
||||
nonlocal added_entities
|
||||
|
||||
if (
|
||||
coordinator.data.configuration_control is ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control is ConfigurationControl.LOCAL
|
||||
and not added_entities
|
||||
):
|
||||
entities = []
|
||||
@@ -84,7 +84,8 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
added_entities = True
|
||||
elif (
|
||||
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control
|
||||
is not ConfigurationControl.LOCAL
|
||||
and added_entities
|
||||
):
|
||||
entity_registry = er.async_get(hass)
|
||||
@@ -104,11 +105,10 @@ class AirGradientNumber(AirGradientEntity, NumberEntity):
|
||||
"""Defines an AirGradient number entity."""
|
||||
|
||||
entity_description: AirGradientNumberEntityDescription
|
||||
coordinator: AirGradientConfigCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientConfigCoordinator,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: AirGradientNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AirGradient number."""
|
||||
@@ -119,7 +119,7 @@ class AirGradientNumber(AirGradientEntity, NumberEntity):
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the number."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.coordinator.data.config)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the selected value."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AirGradientConfigEntry
|
||||
from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE
|
||||
from .coordinator import AirGradientConfigCoordinator
|
||||
from .coordinator import AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
|
||||
@@ -144,13 +144,11 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AirGradient select entities based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data.config
|
||||
measurement_coordinator = entry.runtime_data.measurement
|
||||
coordinator = entry.runtime_data
|
||||
model = coordinator.data.measures.model
|
||||
|
||||
async_add_entities([AirGradientSelect(coordinator, CONFIG_CONTROL_ENTITY)])
|
||||
|
||||
model = measurement_coordinator.data.model
|
||||
|
||||
added_entities = False
|
||||
|
||||
@callback
|
||||
@@ -158,7 +156,7 @@ async def async_setup_entry(
|
||||
nonlocal added_entities
|
||||
|
||||
if (
|
||||
coordinator.data.configuration_control is ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control is ConfigurationControl.LOCAL
|
||||
and not added_entities
|
||||
):
|
||||
entities: list[AirGradientSelect] = [
|
||||
@@ -179,7 +177,8 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
added_entities = True
|
||||
elif (
|
||||
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control
|
||||
is not ConfigurationControl.LOCAL
|
||||
and added_entities
|
||||
):
|
||||
entity_registry = er.async_get(hass)
|
||||
@@ -201,11 +200,10 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
|
||||
"""Defines an AirGradient select entity."""
|
||||
|
||||
entity_description: AirGradientSelectEntityDescription
|
||||
coordinator: AirGradientConfigCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientConfigCoordinator,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: AirGradientSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AirGradient select."""
|
||||
@@ -216,7 +214,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the state of the select."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.coordinator.data.config)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import AirGradientConfigEntry
|
||||
from .const import PM_STANDARD, PM_STANDARD_REVERSE
|
||||
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
|
||||
from .coordinator import AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AirGradient sensor entities based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data.measurement
|
||||
coordinator = entry.runtime_data
|
||||
listener: Callable[[], None] | None = None
|
||||
not_setup: set[AirGradientMeasurementSensorEntityDescription] = set(
|
||||
MEASUREMENT_SENSOR_TYPES
|
||||
@@ -232,7 +232,7 @@ async def async_setup_entry(
|
||||
not_setup = set()
|
||||
sensors = []
|
||||
for description in sensor_descriptions:
|
||||
if description.value_fn(coordinator.data) is None:
|
||||
if description.value_fn(coordinator.data.measures) is None:
|
||||
not_setup.add(description)
|
||||
else:
|
||||
sensors.append(AirGradientMeasurementSensor(coordinator, description))
|
||||
@@ -248,64 +248,65 @@ async def async_setup_entry(
|
||||
add_entities()
|
||||
|
||||
entities = [
|
||||
AirGradientConfigSensor(entry.runtime_data.config, description)
|
||||
AirGradientConfigSensor(coordinator, description)
|
||||
for description in CONFIG_SENSOR_TYPES
|
||||
]
|
||||
if "L" in coordinator.data.model:
|
||||
if "L" in coordinator.data.measures.model:
|
||||
entities.extend(
|
||||
AirGradientConfigSensor(entry.runtime_data.config, description)
|
||||
AirGradientConfigSensor(coordinator, description)
|
||||
for description in CONFIG_LED_BAR_SENSOR_TYPES
|
||||
)
|
||||
if "I" in coordinator.data.model:
|
||||
if "I" in coordinator.data.measures.model:
|
||||
entities.extend(
|
||||
AirGradientConfigSensor(entry.runtime_data.config, description)
|
||||
AirGradientConfigSensor(coordinator, description)
|
||||
for description in CONFIG_DISPLAY_SENSOR_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirGradientMeasurementSensor(AirGradientEntity, SensorEntity):
|
||||
class AirGradientSensor(AirGradientEntity, SensorEntity):
|
||||
"""Defines an AirGradient sensor."""
|
||||
|
||||
entity_description: AirGradientMeasurementSensorEntityDescription
|
||||
coordinator: AirGradientMeasurementCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientMeasurementCoordinator,
|
||||
description: AirGradientMeasurementSensorEntityDescription,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize airgradient sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
|
||||
|
||||
|
||||
class AirGradientMeasurementSensor(AirGradientSensor):
|
||||
"""Defines an AirGradient sensor."""
|
||||
|
||||
entity_description: AirGradientMeasurementSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.coordinator.data.measures)
|
||||
|
||||
|
||||
class AirGradientConfigSensor(AirGradientEntity, SensorEntity):
|
||||
class AirGradientConfigSensor(AirGradientSensor):
|
||||
"""Defines an AirGradient sensor."""
|
||||
|
||||
entity_description: AirGradientConfigSensorEntityDescription
|
||||
coordinator: AirGradientConfigCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientConfigCoordinator,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: AirGradientConfigSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize airgradient sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
|
||||
super().__init__(coordinator, description)
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control
|
||||
is not ConfigurationControl.LOCAL
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.coordinator.data.config)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AirGradientConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirGradientConfigCoordinator
|
||||
from .coordinator import AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AirGradient switch entities based on a config entry."""
|
||||
coordinator = entry.runtime_data.config
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
added_entities = False
|
||||
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
nonlocal added_entities
|
||||
|
||||
if (
|
||||
coordinator.data.configuration_control is ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control is ConfigurationControl.LOCAL
|
||||
and not added_entities
|
||||
):
|
||||
async_add_entities(
|
||||
@@ -63,7 +63,8 @@ async def async_setup_entry(
|
||||
)
|
||||
added_entities = True
|
||||
elif (
|
||||
coordinator.data.configuration_control is not ConfigurationControl.LOCAL
|
||||
coordinator.data.config.configuration_control
|
||||
is not ConfigurationControl.LOCAL
|
||||
and added_entities
|
||||
):
|
||||
entity_registry = er.async_get(hass)
|
||||
@@ -82,11 +83,10 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity):
|
||||
"""Defines an AirGradient switch entity."""
|
||||
|
||||
entity_description: AirGradientSwitchEntityDescription
|
||||
coordinator: AirGradientConfigCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirGradientConfigCoordinator,
|
||||
coordinator: AirGradientCoordinator,
|
||||
description: AirGradientSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AirGradient switch."""
|
||||
@@ -97,7 +97,7 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the switch."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.coordinator.data.config)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AirGradientConfigEntry, AirGradientMeasurementCoordinator
|
||||
from . import AirGradientConfigEntry, AirGradientCoordinator
|
||||
from .entity import AirGradientEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
@@ -20,18 +20,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Airgradient update platform."""
|
||||
|
||||
data = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities([AirGradientUpdate(data.measurement)], True)
|
||||
async_add_entities([AirGradientUpdate(coordinator)], True)
|
||||
|
||||
|
||||
class AirGradientUpdate(AirGradientEntity, UpdateEntity):
|
||||
"""Representation of Airgradient Update."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
coordinator: AirGradientMeasurementCoordinator
|
||||
|
||||
def __init__(self, coordinator: AirGradientMeasurementCoordinator) -> None:
|
||||
def __init__(self, coordinator: AirGradientCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-update"
|
||||
@@ -44,7 +43,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
|
||||
@property
|
||||
def installed_version(self) -> str:
|
||||
"""Return the installed version of the entity."""
|
||||
return self.coordinator.data.firmware_version
|
||||
return self.coordinator.data.measures.firmware_version
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.2"]
|
||||
"requirements": ["aioairzone-cloud==0.6.5"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,16 @@ from aioairzone_cloud.const import (
|
||||
AZD_AQ_PM_10,
|
||||
AZD_CPU_USAGE,
|
||||
AZD_HUMIDITY,
|
||||
AZD_INDOOR_EXCHANGER_TEMP,
|
||||
AZD_INDOOR_RETURN_TEMP,
|
||||
AZD_INDOOR_WORK_TEMP,
|
||||
AZD_MEMORY_FREE,
|
||||
AZD_OUTDOOR_CONDENSER_PRESS,
|
||||
AZD_OUTDOOR_DISCHARGE_TEMP,
|
||||
AZD_OUTDOOR_ELECTRIC_CURRENT,
|
||||
AZD_OUTDOOR_EVAPORATOR_PRESS,
|
||||
AZD_OUTDOOR_EXCHANGER_TEMP,
|
||||
AZD_OUTDOOR_TEMP,
|
||||
AZD_TEMP,
|
||||
AZD_THERMOSTAT_BATTERY,
|
||||
AZD_THERMOSTAT_COVERAGE,
|
||||
@@ -32,7 +41,9 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfInformation,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -48,6 +59,78 @@ from .entity import (
|
||||
)
|
||||
|
||||
AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_INDOOR_EXCHANGER_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="indoor_exchanger_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_INDOOR_RETURN_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="indoor_return_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_INDOOR_WORK_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="indoor_work_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_CONDENSER_PRESS,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_condenser_press",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_DISCHARGE_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_discharge_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_ELECTRIC_CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_electric_current",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_EVAPORATOR_PRESS,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_evaporator_press",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_EXCHANGER_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_exchanger_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_OUTDOOR_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="outdoor_temp",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key=AZD_TEMP,
|
||||
|
||||
@@ -45,6 +45,33 @@
|
||||
"free_memory": {
|
||||
"name": "Free memory"
|
||||
},
|
||||
"indoor_exchanger_temp": {
|
||||
"name": "Indoor exchanger temperature"
|
||||
},
|
||||
"indoor_return_temp": {
|
||||
"name": "Indoor return temperature"
|
||||
},
|
||||
"indoor_work_temp": {
|
||||
"name": "Indoor working temperature"
|
||||
},
|
||||
"outdoor_condenser_press": {
|
||||
"name": "Outdoor condenser pressure"
|
||||
},
|
||||
"outdoor_discharge_temp": {
|
||||
"name": "Outdoor discharge temperature"
|
||||
},
|
||||
"outdoor_electric_current": {
|
||||
"name": "Outdoor electric current"
|
||||
},
|
||||
"outdoor_evaporator_press": {
|
||||
"name": "Outdoor evaporator pressure"
|
||||
},
|
||||
"outdoor_exchanger_temp": {
|
||||
"name": "Outdoor exchanger temperature"
|
||||
},
|
||||
"outdoor_temp": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"thermostat_coverage": {
|
||||
"name": "Signal percentage"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
@@ -114,6 +115,8 @@ def get_engine(
|
||||
|
||||
all_voices: dict[str, dict[str, str]] = {}
|
||||
|
||||
all_engines: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
all_voices_req = polly_client.describe_voices()
|
||||
|
||||
for voice in all_voices_req.get("Voices", []):
|
||||
@@ -124,8 +127,12 @@ def get_engine(
|
||||
language_code: str | None = voice.get("LanguageCode")
|
||||
if language_code is not None and language_code not in supported_languages:
|
||||
supported_languages.append(language_code)
|
||||
for engine in voice.get("SupportedEngines"):
|
||||
all_engines[engine].add(voice_id)
|
||||
|
||||
return AmazonPollyProvider(polly_client, config, supported_languages, all_voices)
|
||||
return AmazonPollyProvider(
|
||||
polly_client, config, supported_languages, all_voices, all_engines
|
||||
)
|
||||
|
||||
|
||||
class AmazonPollyProvider(Provider):
|
||||
@@ -137,13 +144,16 @@ class AmazonPollyProvider(Provider):
|
||||
config: ConfigType,
|
||||
supported_languages: list[str],
|
||||
all_voices: dict[str, dict[str, str]],
|
||||
all_engines: dict[str, set[str]],
|
||||
) -> None:
|
||||
"""Initialize Amazon Polly provider for TTS."""
|
||||
self.client = polly_client
|
||||
self.config = config
|
||||
self.supported_langs = supported_languages
|
||||
self.all_voices = all_voices
|
||||
self.all_engines = all_engines
|
||||
self.default_voice: str = self.config[CONF_VOICE]
|
||||
self.default_engine: str = self.config[CONF_ENGINE]
|
||||
self.name = "Amazon Polly"
|
||||
|
||||
@property
|
||||
@@ -159,12 +169,12 @@ class AmazonPollyProvider(Provider):
|
||||
@property
|
||||
def default_options(self) -> dict[str, str]:
|
||||
"""Return dict include default options."""
|
||||
return {CONF_VOICE: self.default_voice}
|
||||
return {CONF_VOICE: self.default_voice, CONF_ENGINE: self.default_engine}
|
||||
|
||||
@property
|
||||
def supported_options(self) -> list[str]:
|
||||
"""Return a list of supported options."""
|
||||
return [CONF_VOICE]
|
||||
return [CONF_VOICE, CONF_ENGINE]
|
||||
|
||||
def get_tts_audio(
|
||||
self,
|
||||
@@ -179,9 +189,14 @@ class AmazonPollyProvider(Provider):
|
||||
_LOGGER.error("%s does not support the %s language", voice_id, language)
|
||||
return None, None
|
||||
|
||||
engine = options.get(CONF_ENGINE, self.default_engine)
|
||||
if voice_id not in self.all_engines[engine]:
|
||||
_LOGGER.error("%s does not support the %s engine", voice_id, engine)
|
||||
return None, None
|
||||
|
||||
_LOGGER.debug("Requesting TTS file for text: %s", message)
|
||||
resp = self.client.synthesize_speech(
|
||||
Engine=self.config[CONF_ENGINE],
|
||||
Engine=engine,
|
||||
OutputFormat=self.config[CONF_OUTPUT_FORMAT],
|
||||
SampleRate=self.config[CONF_SAMPLE_RATE],
|
||||
Text=message,
|
||||
|
||||
@@ -8,7 +8,7 @@ LOGGER = logging.getLogger(__package__)
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "claude-3-5-sonnet-20240620"
|
||||
RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
RECOMMENDED_MAX_TOKENS = 1024
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
|
||||
@@ -56,7 +56,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
refresh_token = await api.authenticate(
|
||||
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except ApiException:
|
||||
except (ApiException, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationFailed:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -56,7 +56,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
|
||||
so entities can quickly look up their data.
|
||||
"""
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
async with asyncio.timeout(30):
|
||||
# Check if the refresh token is expired
|
||||
expiry_time = (
|
||||
self.refresh_token_creation_time
|
||||
@@ -72,7 +72,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
|
||||
softeners = await self.aquacell_api.get_all_softeners()
|
||||
except AuthenticationFailed as err:
|
||||
raise ConfigEntryError from err
|
||||
except AquacellApiException as err:
|
||||
except (AquacellApiException, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
return {softener.dsn: softener for softener in softeners}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -16,6 +17,7 @@ from .const import (
|
||||
DATA_LAST_WAKE_UP,
|
||||
DOMAIN,
|
||||
EVENT_RECORDING,
|
||||
OPTION_PREFERRED,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
@@ -57,6 +59,7 @@ __all__ = (
|
||||
"PipelineNotFound",
|
||||
"WakeWordSettings",
|
||||
"EVENT_RECORDING",
|
||||
"OPTION_PREFERRED",
|
||||
"SAMPLES_PER_CHUNK",
|
||||
"SAMPLE_RATE",
|
||||
"SAMPLE_WIDTH",
|
||||
@@ -99,7 +102,7 @@ async def async_pipeline_from_audio_stream(
|
||||
wake_word_phrase: str | None = None,
|
||||
pipeline_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
tts_audio_output: str | None = None,
|
||||
tts_audio_output: str | dict[str, Any] | None = None,
|
||||
wake_word_settings: WakeWordSettings | None = None,
|
||||
audio_settings: AudioSettings | None = None,
|
||||
device_id: str | None = None,
|
||||
|
||||
@@ -22,3 +22,5 @@ SAMPLE_CHANNELS = 1 # mono
|
||||
MS_PER_CHUNK = 10
|
||||
SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
|
||||
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
|
||||
|
||||
OPTION_PREFERRED = "preferred"
|
||||
|
||||
@@ -504,7 +504,7 @@ class AudioSettings:
|
||||
is_vad_enabled: bool = True
|
||||
"""True if VAD is used to determine the end of the voice command."""
|
||||
|
||||
silence_seconds: float = 0.5
|
||||
silence_seconds: float = 0.7
|
||||
"""Seconds of silence after voice command has ended."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -538,7 +538,7 @@ class PipelineRun:
|
||||
language: str = None # type: ignore[assignment]
|
||||
runner_data: Any | None = None
|
||||
intent_agent: str | None = None
|
||||
tts_audio_output: str | None = None
|
||||
tts_audio_output: str | dict[str, Any] | None = None
|
||||
wake_word_settings: WakeWordSettings | None = None
|
||||
audio_settings: AudioSettings = field(default_factory=AudioSettings)
|
||||
|
||||
@@ -906,6 +906,8 @@ class PipelineRun:
|
||||
metadata,
|
||||
self._speech_to_text_stream(audio_stream=stream, stt_vad=stt_vad),
|
||||
)
|
||||
except (asyncio.CancelledError, TimeoutError):
|
||||
raise # expected
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during speech-to-text")
|
||||
raise SpeechToTextError(
|
||||
@@ -1052,12 +1054,15 @@ class PipelineRun:
|
||||
if self.pipeline.tts_voice is not None:
|
||||
tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice
|
||||
|
||||
if self.tts_audio_output is not None:
|
||||
if isinstance(self.tts_audio_output, dict):
|
||||
tts_options.update(self.tts_audio_output)
|
||||
elif isinstance(self.tts_audio_output, str):
|
||||
tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output
|
||||
if self.tts_audio_output == "wav":
|
||||
# 16 Khz, 16-bit mono
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = SAMPLE_RATE
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = SAMPLE_CHANNELS
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH
|
||||
|
||||
try:
|
||||
options_supported = await tts.async_support_options(
|
||||
|
||||
@@ -9,12 +9,10 @@ from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import collection, entity_registry as er, restore_state
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, OPTION_PREFERRED
|
||||
from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection
|
||||
from .vad import VadSensitivity
|
||||
|
||||
OPTION_PREFERRED = "preferred"
|
||||
|
||||
|
||||
@callback
|
||||
def get_chosen_pipeline(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Base class for assist satellite entities."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, AssistSatelliteEntityFeature
|
||||
from .entity import AssistSatelliteEntity, AssistSatelliteEntityDescription
|
||||
from .errors import SatelliteBusyError
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AssistSatelliteEntity",
|
||||
"AssistSatelliteEntityDescription",
|
||||
"AssistSatelliteEntityFeature",
|
||||
"SatelliteBusyError",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
component = hass.data[DOMAIN] = EntityComponent[AssistSatelliteEntity](
|
||||
_LOGGER, DOMAIN, hass
|
||||
)
|
||||
await component.async_setup(config)
|
||||
|
||||
component.async_register_entity_service(
|
||||
"announce",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
),
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
async_register_websocket_api(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
return await component.async_unload_entry(entry)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Constants for assist satellite."""
|
||||
|
||||
from enum import IntFlag
|
||||
|
||||
DOMAIN = "assist_satellite"
|
||||
|
||||
|
||||
class AssistSatelliteEntityFeature(IntFlag):
|
||||
"""Supported features of Assist satellite entity."""
|
||||
|
||||
ANNOUNCE = 1
|
||||
"""Device supports remotely triggered announcements."""
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Assist satellite entity."""
|
||||
|
||||
from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
|
||||
from homeassistant.components import media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
OPTION_PREFERRED,
|
||||
AudioSettings,
|
||||
PipelineEvent,
|
||||
PipelineEventType,
|
||||
PipelineStage,
|
||||
async_get_pipeline,
|
||||
async_get_pipelines,
|
||||
async_pipeline_from_audio_stream,
|
||||
vad,
|
||||
)
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.components.tts.media_source import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.helpers import entity
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import AssistSatelliteEntityFeature
|
||||
from .errors import AssistSatelliteError, SatelliteBusyError
|
||||
|
||||
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssistSatelliteState(StrEnum):
|
||||
"""Valid states of an Assist satellite entity."""
|
||||
|
||||
LISTENING_WAKE_WORD = "listening_wake_word"
|
||||
"""Device is streaming audio for wake word detection to Home Assistant."""
|
||||
|
||||
LISTENING_COMMAND = "listening_command"
|
||||
"""Device is streaming audio with the voice command to Home Assistant."""
|
||||
|
||||
PROCESSING = "processing"
|
||||
"""Home Assistant is processing the voice command."""
|
||||
|
||||
RESPONDING = "responding"
|
||||
"""Device is speaking the response."""
|
||||
|
||||
|
||||
class AssistSatelliteEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes Assist satellite entities."""
|
||||
|
||||
|
||||
class AssistSatelliteEntity(entity.Entity):
|
||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||
|
||||
entity_description: AssistSatelliteEntityDescription
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = AssistSatelliteEntityFeature(0)
|
||||
_attr_pipeline_entity_id: str | None = None
|
||||
_attr_vad_sensitivity_entity_id: str | None = None
|
||||
|
||||
_conversation_id: str | None = None
|
||||
_conversation_id_time: float | None = None
|
||||
|
||||
_run_has_tts: bool = False
|
||||
_is_announcing = False
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
|
||||
__assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD
|
||||
|
||||
@final
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return state of the entity."""
|
||||
return self.__assist_satellite_state
|
||||
|
||||
@property
|
||||
def pipeline_entity_id(self) -> str | None:
|
||||
"""Entity ID of the pipeline to use for the next conversation."""
|
||||
return self._attr_pipeline_entity_id
|
||||
|
||||
@property
|
||||
def vad_sensitivity_entity_id(self) -> str | None:
|
||||
"""Entity ID of the VAD sensitivity to use for the next conversation."""
|
||||
return self._attr_vad_sensitivity_entity_id
|
||||
|
||||
async def async_intercept_wake_word(self) -> str | None:
|
||||
"""Intercept the next wake word from the satellite.
|
||||
|
||||
Returns the detected wake word phrase or None.
|
||||
"""
|
||||
if self._wake_word_intercept_future is not None:
|
||||
raise SatelliteBusyError("Wake word interception already in progress")
|
||||
|
||||
# Will cause next wake word to be intercepted in
|
||||
# async_accept_pipeline_from_satellite
|
||||
self._wake_word_intercept_future = asyncio.Future()
|
||||
|
||||
_LOGGER.debug("Next wake word will be intercepted: %s", self.entity_id)
|
||||
|
||||
try:
|
||||
return await self._wake_word_intercept_future
|
||||
finally:
|
||||
self._wake_word_intercept_future = None
|
||||
|
||||
async def async_internal_announce(
|
||||
self,
|
||||
message: str | None = None,
|
||||
media_id: str | None = None,
|
||||
) -> None:
|
||||
"""Play and show an announcement on the satellite.
|
||||
|
||||
If media_id is not provided, message is synthesized to
|
||||
audio with the selected pipeline.
|
||||
|
||||
If media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
Calls async_announce with message and media id.
|
||||
"""
|
||||
if message is None:
|
||||
message = ""
|
||||
|
||||
if not media_id:
|
||||
# Synthesize audio and get URL
|
||||
pipeline_id = self._resolve_pipeline()
|
||||
pipeline = async_get_pipeline(self.hass, pipeline_id)
|
||||
|
||||
tts_options: dict[str, Any] = {}
|
||||
if pipeline.tts_voice is not None:
|
||||
tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
|
||||
|
||||
media_id = tts_generate_media_source_id(
|
||||
self.hass,
|
||||
message,
|
||||
engine=pipeline.tts_engine,
|
||||
language=pipeline.tts_language,
|
||||
options=tts_options,
|
||||
)
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media = await media_source.async_resolve_media(
|
||||
self.hass,
|
||||
media_id,
|
||||
None,
|
||||
)
|
||||
media_id = media.url
|
||||
|
||||
# Resolve to full URL
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
if self._is_announcing:
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
|
||||
try:
|
||||
# Block until announcement is finished
|
||||
await self.async_announce(message, media_id)
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
|
||||
async def async_announce(self, message: str, media_id: str) -> None:
|
||||
"""Announce media on the satellite.
|
||||
|
||||
Should block until the announcement is done playing.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_accept_pipeline_from_satellite(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
start_stage: PipelineStage = PipelineStage.STT,
|
||||
end_stage: PipelineStage = PipelineStage.TTS,
|
||||
wake_word_phrase: str | None = None,
|
||||
) -> None:
|
||||
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
||||
if self._wake_word_intercept_future and start_stage in (
|
||||
PipelineStage.WAKE_WORD,
|
||||
PipelineStage.STT,
|
||||
):
|
||||
if start_stage == PipelineStage.WAKE_WORD:
|
||||
self._wake_word_intercept_future.set_exception(
|
||||
AssistSatelliteError(
|
||||
"Only on-device wake words currently supported"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Intercepting wake word and immediately end pipeline
|
||||
_LOGGER.debug(
|
||||
"Intercepted wake word: %s (entity_id=%s)",
|
||||
wake_word_phrase,
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
if wake_word_phrase is None:
|
||||
self._wake_word_intercept_future.set_exception(
|
||||
AssistSatelliteError("No wake word phrase provided")
|
||||
)
|
||||
else:
|
||||
self._wake_word_intercept_future.set_result(wake_word_phrase)
|
||||
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
||||
return
|
||||
|
||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||
|
||||
# Refresh context if necessary
|
||||
if (
|
||||
(self._context is None)
|
||||
or (self._context_set is None)
|
||||
or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS)
|
||||
):
|
||||
self.async_set_context(Context())
|
||||
|
||||
assert self._context is not None
|
||||
|
||||
# Reset conversation id if necessary
|
||||
if (self._conversation_id_time is None) or (
|
||||
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
|
||||
):
|
||||
self._conversation_id = None
|
||||
|
||||
if self._conversation_id is None:
|
||||
self._conversation_id = ulid.ulid()
|
||||
|
||||
# Update timeout
|
||||
self._conversation_id_time = time.monotonic()
|
||||
|
||||
# Set entity state based on pipeline events
|
||||
self._run_has_tts = False
|
||||
|
||||
await async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self._context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=audio_stream,
|
||||
pipeline_id=self._resolve_pipeline(),
|
||||
conversation_id=self._conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output="wav",
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
silence_seconds=self._resolve_vad_sensitivity()
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
|
||||
@callback
|
||||
def _internal_on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Set state based on pipeline stage."""
|
||||
if event.type is PipelineEventType.WAKE_WORD_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
elif event.type is PipelineEventType.STT_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING_COMMAND)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
self._set_state(AssistSatelliteState.PROCESSING)
|
||||
elif event.type is PipelineEventType.TTS_START:
|
||||
# Wait until tts_response_finished is called to return to waiting state
|
||||
self._run_has_tts = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
elif event.type is PipelineEventType.RUN_END:
|
||||
if not self._run_has_tts:
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
|
||||
self.on_pipeline_event(event)
|
||||
|
||||
@callback
|
||||
def _set_state(self, state: AssistSatelliteState) -> None:
|
||||
"""Set the entity's state."""
|
||||
self.__assist_satellite_state = state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def tts_response_finished(self) -> None:
|
||||
"""Tell entity that the text-to-speech response has finished playing."""
|
||||
self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD)
|
||||
|
||||
@callback
|
||||
def _resolve_pipeline(self) -> str | None:
|
||||
"""Resolve pipeline from select entity to id.
|
||||
|
||||
Return None to make async_get_pipeline look up the preferred pipeline.
|
||||
"""
|
||||
if not (pipeline_entity_id := self.pipeline_entity_id):
|
||||
return None
|
||||
|
||||
if (pipeline_entity_state := self.hass.states.get(pipeline_entity_id)) is None:
|
||||
raise RuntimeError("Pipeline entity not found")
|
||||
|
||||
if pipeline_entity_state.state != OPTION_PREFERRED:
|
||||
# Resolve pipeline by name
|
||||
for pipeline in async_get_pipelines(self.hass):
|
||||
if pipeline.name == pipeline_entity_state.state:
|
||||
return pipeline.id
|
||||
|
||||
return None
|
||||
|
||||
@callback
|
||||
def _resolve_vad_sensitivity(self) -> float:
|
||||
"""Resolve VAD sensitivity from select entity to enum."""
|
||||
vad_sensitivity = vad.VadSensitivity.DEFAULT
|
||||
|
||||
if vad_sensitivity_entity_id := self.vad_sensitivity_entity_id:
|
||||
if (
|
||||
vad_sensitivity_state := self.hass.states.get(vad_sensitivity_entity_id)
|
||||
) is None:
|
||||
raise RuntimeError("VAD sensitivity entity not found")
|
||||
|
||||
vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state)
|
||||
|
||||
return vad.VadSensitivity.to_seconds(vad_sensitivity)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Errors for assist satellite."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class AssistSatelliteError(HomeAssistantError):
|
||||
"""Base class for assist satellite errors."""
|
||||
|
||||
|
||||
class SatelliteBusyError(AssistSatelliteError):
|
||||
"""Satellite is busy and cannot handle the request."""
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:account-voice"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"announce": {
|
||||
"service": "mdi:bullhorn"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "assist_satellite",
|
||||
"name": "Assist Satellite",
|
||||
"codeowners": ["@home-assistant/core", "@synesthesiam"],
|
||||
"dependencies": ["assist_pipeline", "stt", "tts"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
announce:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE
|
||||
fields:
|
||||
message:
|
||||
required: false
|
||||
example: "Time to wake up!"
|
||||
selector:
|
||||
text:
|
||||
media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"title": "Assist satellite",
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "Assist satellite",
|
||||
"state": {
|
||||
"listening_wake_word": "Wake word",
|
||||
"listening_command": "Voice command",
|
||||
"responding": "Responding",
|
||||
"processing": "Processing"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"announce": {
|
||||
"name": "Announce",
|
||||
"description": "Let the satellite announce a message.",
|
||||
"fields": {
|
||||
"message": {
|
||||
"name": "Message",
|
||||
"description": "The message to announce."
|
||||
},
|
||||
"media_id": {
|
||||
"name": "Media ID",
|
||||
"description": "The media ID to announce instead of using text-to-speech."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Assist satellite Websocket API."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AssistSatelliteEntity
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_api(hass: HomeAssistant) -> None:
|
||||
"""Register the websocket API."""
|
||||
websocket_api.async_register_command(hass, websocket_intercept_wake_word)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "assist_satellite/intercept_wake_word",
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_intercept_wake_word(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Intercept the next wake word from a satellite."""
|
||||
component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN]
|
||||
satellite = component.get_entity(msg["entity_id"])
|
||||
if satellite is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
|
||||
)
|
||||
return
|
||||
|
||||
wake_word_phrase = await satellite.async_intercept_wake_word()
|
||||
connection.send_result(msg["id"], {"wake_word_phrase": wake_word_phrase})
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import namedtuple
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
@@ -40,17 +41,23 @@ from .const import (
|
||||
PROTOCOL_HTTPS,
|
||||
PROTOCOL_TELNET,
|
||||
SENSORS_BYTES,
|
||||
SENSORS_CPU,
|
||||
SENSORS_LOAD_AVG,
|
||||
SENSORS_MEMORY,
|
||||
SENSORS_RATES,
|
||||
SENSORS_TEMPERATURES,
|
||||
SENSORS_TEMPERATURES_LEGACY,
|
||||
SENSORS_UPTIME,
|
||||
)
|
||||
|
||||
SENSORS_TYPE_BYTES = "sensors_bytes"
|
||||
SENSORS_TYPE_COUNT = "sensors_count"
|
||||
SENSORS_TYPE_CPU = "sensors_cpu"
|
||||
SENSORS_TYPE_LOAD_AVG = "sensors_load_avg"
|
||||
SENSORS_TYPE_MEMORY = "sensors_memory"
|
||||
SENSORS_TYPE_RATES = "sensors_rates"
|
||||
SENSORS_TYPE_TEMPERATURES = "sensors_temperatures"
|
||||
SENSORS_TYPE_UPTIME = "sensors_uptime"
|
||||
|
||||
WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) # noqa: PYI024
|
||||
|
||||
@@ -346,6 +353,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
|
||||
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return a dictionary of available sensors for this bridge."""
|
||||
sensors_cpu = await self._get_available_cpu_sensors()
|
||||
sensors_temperatures = await self._get_available_temperature_sensors()
|
||||
sensors_loadavg = await self._get_loadavg_sensors_availability()
|
||||
return {
|
||||
@@ -353,20 +361,49 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
KEY_SENSORS: SENSORS_BYTES,
|
||||
KEY_METHOD: self._get_bytes,
|
||||
},
|
||||
SENSORS_TYPE_CPU: {
|
||||
KEY_SENSORS: sensors_cpu,
|
||||
KEY_METHOD: self._get_cpu_usage,
|
||||
},
|
||||
SENSORS_TYPE_LOAD_AVG: {
|
||||
KEY_SENSORS: sensors_loadavg,
|
||||
KEY_METHOD: self._get_load_avg,
|
||||
},
|
||||
SENSORS_TYPE_MEMORY: {
|
||||
KEY_SENSORS: SENSORS_MEMORY,
|
||||
KEY_METHOD: self._get_memory_usage,
|
||||
},
|
||||
SENSORS_TYPE_RATES: {
|
||||
KEY_SENSORS: SENSORS_RATES,
|
||||
KEY_METHOD: self._get_rates,
|
||||
},
|
||||
SENSORS_TYPE_UPTIME: {
|
||||
KEY_SENSORS: SENSORS_UPTIME,
|
||||
KEY_METHOD: self._get_uptime,
|
||||
},
|
||||
SENSORS_TYPE_TEMPERATURES: {
|
||||
KEY_SENSORS: sensors_temperatures,
|
||||
KEY_METHOD: self._get_temperatures,
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_available_cpu_sensors(self) -> list[str]:
|
||||
"""Check which cpu information is available on the router."""
|
||||
try:
|
||||
available_cpu = await self._api.async_get_cpu_usage()
|
||||
available_sensors = [t for t in SENSORS_CPU if t in available_cpu]
|
||||
except AsusWrtError as exc:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Failed checking cpu sensor availability for ASUS router"
|
||||
" %s. Exception: %s"
|
||||
),
|
||||
self.host,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
return available_sensors
|
||||
|
||||
async def _get_available_temperature_sensors(self) -> list[str]:
|
||||
"""Check which temperature information is available on the router."""
|
||||
try:
|
||||
@@ -415,3 +452,25 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
async def _get_temperatures(self) -> Any:
|
||||
"""Fetch temperatures information from the router."""
|
||||
return await self._api.async_get_temperatures()
|
||||
|
||||
@handle_errors_and_zip(AsusWrtError, None)
|
||||
async def _get_cpu_usage(self) -> Any:
|
||||
"""Fetch cpu information from the router."""
|
||||
return await self._api.async_get_cpu_usage()
|
||||
|
||||
@handle_errors_and_zip(AsusWrtError, None)
|
||||
async def _get_memory_usage(self) -> Any:
|
||||
"""Fetch memory information from the router."""
|
||||
return await self._api.async_get_memory_usage()
|
||||
|
||||
async def _get_uptime(self) -> dict[str, Any]:
|
||||
"""Fetch uptime from the router."""
|
||||
try:
|
||||
uptimes = await self._api.async_get_uptime()
|
||||
except AsusWrtError as exc:
|
||||
raise UpdateFailed(exc) from exc
|
||||
|
||||
last_boot = datetime.fromisoformat(uptimes["last_boot"])
|
||||
uptime = uptimes["uptime"]
|
||||
|
||||
return dict(zip(SENSORS_UPTIME, [last_boot, uptime], strict=False))
|
||||
|
||||
@@ -27,7 +27,20 @@ PROTOCOL_TELNET = "telnet"
|
||||
# Sensors
|
||||
SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"]
|
||||
SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"]
|
||||
SENSORS_CPU = [
|
||||
"cpu_total_usage",
|
||||
"cpu1_usage",
|
||||
"cpu2_usage",
|
||||
"cpu3_usage",
|
||||
"cpu4_usage",
|
||||
"cpu5_usage",
|
||||
"cpu6_usage",
|
||||
"cpu7_usage",
|
||||
"cpu8_usage",
|
||||
]
|
||||
SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"]
|
||||
SENSORS_MEMORY = ["mem_usage_perc", "mem_free", "mem_used"]
|
||||
SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"]
|
||||
SENSORS_TEMPERATURES_LEGACY = ["2.4GHz", "5.0GHz", "CPU"]
|
||||
SENSORS_TEMPERATURES = [*SENSORS_TEMPERATURES_LEGACY, "5.0GHz_2", "6.0GHz"]
|
||||
SENSORS_UPTIME = ["sensor_last_boot", "sensor_uptime"]
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
},
|
||||
"load_avg_15m": {
|
||||
"default": "mdi:cpu-32-bit"
|
||||
},
|
||||
"cpu_usage": {
|
||||
"default": "mdi:cpu-32-bit"
|
||||
},
|
||||
"cpu_core_usage": {
|
||||
"default": "mdi:cpu-32-bit"
|
||||
},
|
||||
"memory_usage": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"memory_free": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"memory_used": {
|
||||
"default": "mdi:memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfDataRate,
|
||||
UnitOfInformation,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -30,9 +32,12 @@ from .const import (
|
||||
KEY_SENSORS,
|
||||
SENSORS_BYTES,
|
||||
SENSORS_CONNECTED_DEVICE,
|
||||
SENSORS_CPU,
|
||||
SENSORS_LOAD_AVG,
|
||||
SENSORS_MEMORY,
|
||||
SENSORS_RATES,
|
||||
SENSORS_TEMPERATURES,
|
||||
SENSORS_UPTIME,
|
||||
)
|
||||
from .router import AsusWrtRouter
|
||||
|
||||
@@ -46,6 +51,19 @@ class AsusWrtSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
UNIT_DEVICES = "Devices"
|
||||
|
||||
CPU_CORE_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = tuple(
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=sens_key,
|
||||
translation_key="cpu_core_usage",
|
||||
translation_placeholders={"core_id": str(core_id)},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
)
|
||||
for core_id, sens_key in enumerate(SENSORS_CPU[1:], start=1)
|
||||
)
|
||||
CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = (
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_CONNECTED_DEVICE[0],
|
||||
@@ -167,6 +185,61 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_MEMORY[0],
|
||||
translation_key="memory_usage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_MEMORY[1],
|
||||
translation_key="memory_free",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=2,
|
||||
factor=1024,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_MEMORY[2],
|
||||
translation_key="memory_used",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=2,
|
||||
factor=1024,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_UPTIME[0],
|
||||
translation_key="last_boot",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_UPTIME[1],
|
||||
translation_key="uptime",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AsusWrtSensorEntityDescription(
|
||||
key=SENSORS_CPU[0],
|
||||
translation_key="cpu_usage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
*CPU_CORE_SENSORS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,27 @@
|
||||
},
|
||||
"6ghz_temperature": {
|
||||
"name": "6GHz Temperature"
|
||||
},
|
||||
"cpu_usage": {
|
||||
"name": "CPU usage"
|
||||
},
|
||||
"cpu_core_usage": {
|
||||
"name": "CPU core {core_id} usage"
|
||||
},
|
||||
"memory_usage": {
|
||||
"name": "Memory usage"
|
||||
},
|
||||
"memory_free": {
|
||||
"name": "Memory free"
|
||||
},
|
||||
"memory_used": {
|
||||
"name": "Memory used"
|
||||
},
|
||||
"last_boot": {
|
||||
"name": "Last boot"
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==8.6.2", "yalexs-ble==2.4.3"]
|
||||
"requirements": ["yalexs==8.6.3", "yalexs-ble==2.4.3"]
|
||||
}
|
||||
|
||||
@@ -991,15 +991,15 @@ async def _create_automation_entities(
|
||||
|
||||
# Add trigger variables to variables
|
||||
variables = None
|
||||
if CONF_TRIGGER_VARIABLES in config_block:
|
||||
if CONF_TRIGGER_VARIABLES in config_block and CONF_VARIABLES in config_block:
|
||||
variables = ScriptVariables(
|
||||
dict(config_block[CONF_TRIGGER_VARIABLES].as_dict())
|
||||
)
|
||||
if CONF_VARIABLES in config_block:
|
||||
if variables:
|
||||
variables.variables.update(config_block[CONF_VARIABLES].as_dict())
|
||||
else:
|
||||
variables = config_block[CONF_VARIABLES]
|
||||
variables.variables.update(config_block[CONF_VARIABLES].as_dict())
|
||||
elif CONF_TRIGGER_VARIABLES in config_block:
|
||||
variables = config_block[CONF_TRIGGER_VARIABLES]
|
||||
elif CONF_VARIABLES in config_block:
|
||||
variables = config_block[CONF_VARIABLES]
|
||||
|
||||
entity = AutomationEntity(
|
||||
automation_id,
|
||||
|
||||
@@ -293,6 +293,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity):
|
||||
identifiers={(DOMAIN, self._device.uuid)},
|
||||
manufacturer="Awair",
|
||||
model=self._device.model,
|
||||
model_id=self._device.device_type,
|
||||
name=(
|
||||
self._device.name
|
||||
or cast(ConfigEntry, self.coordinator.config_entry).title
|
||||
|
||||
@@ -6,8 +6,14 @@ import logging
|
||||
from typing import Final
|
||||
|
||||
from aioazuredevops.client import DevOpsClient
|
||||
from aioazuredevops.helper import (
|
||||
WorkItemTypeAndState,
|
||||
work_item_types_states_filter,
|
||||
work_items_by_type_and_state,
|
||||
)
|
||||
from aioazuredevops.models.build import Build
|
||||
from aioazuredevops.models.core import Project
|
||||
from aioazuredevops.models.work_item_type import Category
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -20,6 +26,7 @@ from .const import CONF_ORG, DOMAIN
|
||||
from .data import AzureDevOpsData
|
||||
|
||||
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
|
||||
IGNORED_CATEGORIES: Final[list[Category]] = [Category.COMPLETED, Category.REMOVED]
|
||||
|
||||
|
||||
def ado_exception_none_handler(func: Callable) -> Callable:
|
||||
@@ -105,13 +112,60 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]):
|
||||
BUILDS_QUERY,
|
||||
)
|
||||
|
||||
@ado_exception_none_handler
|
||||
async def _get_work_items(
|
||||
self, project_name: str
|
||||
) -> list[WorkItemTypeAndState] | None:
|
||||
"""Get the work items."""
|
||||
|
||||
if (
|
||||
work_item_types := await self.client.get_work_item_types(
|
||||
self.organization,
|
||||
project_name,
|
||||
)
|
||||
) is None:
|
||||
# If no work item types are returned, return an empty list
|
||||
return []
|
||||
|
||||
if (
|
||||
work_item_ids := await self.client.get_work_item_ids(
|
||||
self.organization,
|
||||
project_name,
|
||||
# Filter out completed and removed work items so we only get active work items
|
||||
states=work_item_types_states_filter(
|
||||
work_item_types,
|
||||
ignored_categories=IGNORED_CATEGORIES,
|
||||
),
|
||||
)
|
||||
) is None:
|
||||
# If no work item ids are returned, return an empty list
|
||||
return []
|
||||
|
||||
if (
|
||||
work_items := await self.client.get_work_items(
|
||||
self.organization,
|
||||
project_name,
|
||||
work_item_ids,
|
||||
)
|
||||
) is None:
|
||||
# If no work items are returned, return an empty list
|
||||
return []
|
||||
|
||||
return work_items_by_type_and_state(
|
||||
work_item_types,
|
||||
work_items,
|
||||
ignored_categories=IGNORED_CATEGORIES,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AzureDevOpsData:
|
||||
"""Fetch data from Azure DevOps."""
|
||||
# Get the builds from the project
|
||||
builds = await self._get_builds(self.project.name)
|
||||
work_items = await self._get_work_items(self.project.name)
|
||||
|
||||
return AzureDevOpsData(
|
||||
organization=self.organization,
|
||||
project=self.project,
|
||||
builds=builds,
|
||||
work_items=work_items,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioazuredevops.helper import WorkItemTypeAndState
|
||||
from aioazuredevops.models.build import Build
|
||||
from aioazuredevops.models.core import Project
|
||||
|
||||
@@ -13,3 +14,4 @@ class AzureDevOpsData:
|
||||
organization: str
|
||||
project: Project
|
||||
builds: list[Build]
|
||||
work_items: list[WorkItemTypeAndState]
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"sensor": {
|
||||
"latest_build": {
|
||||
"default": "mdi:pipe"
|
||||
},
|
||||
"work_item_count": {
|
||||
"default": "mdi:ticket"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioazuredevops.helper import WorkItemState, WorkItemTypeAndState
|
||||
from aioazuredevops.models.build import Build
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -29,12 +30,19 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class describing Azure DevOps base build sensor entities."""
|
||||
"""Class describing Azure DevOps build sensor entities."""
|
||||
|
||||
attr_fn: Callable[[Build], dict[str, Any] | None] = lambda _: None
|
||||
value_fn: Callable[[Build], datetime | StateType]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AzureDevOpsWorkItemSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class describing Azure DevOps work item sensor entities."""
|
||||
|
||||
value_fn: Callable[[WorkItemState], datetime | StateType]
|
||||
|
||||
|
||||
BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = (
|
||||
# Attributes are deprecated in 2024.7 and can be removed in 2025.1
|
||||
AzureDevOpsBuildSensorEntityDescription(
|
||||
@@ -116,6 +124,16 @@ BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, .
|
||||
),
|
||||
)
|
||||
|
||||
BASE_WORK_ITEM_SENSOR_DESCRIPTIONS: tuple[
|
||||
AzureDevOpsWorkItemSensorEntityDescription, ...
|
||||
] = (
|
||||
AzureDevOpsWorkItemSensorEntityDescription(
|
||||
key="work_item_count",
|
||||
translation_key="work_item_count",
|
||||
value_fn=lambda work_item_state: len(work_item_state.work_items),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_datetime(value: str | None) -> datetime | None:
|
||||
"""Parse datetime string."""
|
||||
@@ -134,7 +152,7 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
initial_builds: list[Build] = coordinator.data.builds
|
||||
|
||||
async_add_entities(
|
||||
entities: list[SensorEntity] = [
|
||||
AzureDevOpsBuildSensor(
|
||||
coordinator,
|
||||
description,
|
||||
@@ -143,8 +161,22 @@ async def async_setup_entry(
|
||||
for description in BASE_BUILD_SENSOR_DESCRIPTIONS
|
||||
for key, build in enumerate(initial_builds)
|
||||
if build.project and build.definition
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
AzureDevOpsWorkItemSensor(
|
||||
coordinator,
|
||||
description,
|
||||
key,
|
||||
state_key,
|
||||
)
|
||||
for description in BASE_WORK_ITEM_SENSOR_DESCRIPTIONS
|
||||
for key, work_item_type_state in enumerate(coordinator.data.work_items)
|
||||
for state_key, _ in enumerate(work_item_type_state.state_items)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
|
||||
"""Define a Azure DevOps build sensor."""
|
||||
@@ -162,8 +194,8 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
self.item_key = item_key
|
||||
self._attr_unique_id = (
|
||||
f"{self.coordinator.data.organization}_"
|
||||
f"{self.build.project.id}_"
|
||||
f"{coordinator.data.organization}_"
|
||||
f"{coordinator.data.project.id}_"
|
||||
f"{self.build.definition.build_id}_"
|
||||
f"{description.key}"
|
||||
)
|
||||
@@ -185,3 +217,48 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the state attributes of the entity."""
|
||||
return self.entity_description.attr_fn(self.build)
|
||||
|
||||
|
||||
class AzureDevOpsWorkItemSensor(AzureDevOpsEntity, SensorEntity):
|
||||
"""Define a Azure DevOps work item sensor."""
|
||||
|
||||
entity_description: AzureDevOpsWorkItemSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AzureDevOpsDataUpdateCoordinator,
|
||||
description: AzureDevOpsWorkItemSensorEntityDescription,
|
||||
wits_key: int,
|
||||
state_key: int,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self.wits_key = wits_key
|
||||
self.state_key = state_key
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.data.organization}_"
|
||||
f"{coordinator.data.project.id}_"
|
||||
f"{self.work_item_type.name}_"
|
||||
f"{self.work_item_state.name}_"
|
||||
f"{description.key}"
|
||||
)
|
||||
self._attr_translation_placeholders = {
|
||||
"item_type": self.work_item_type.name,
|
||||
"item_state": self.work_item_state.name,
|
||||
}
|
||||
|
||||
@property
|
||||
def work_item_type(self) -> WorkItemTypeAndState:
|
||||
"""Return the work item."""
|
||||
return self.coordinator.data.work_items[self.wits_key]
|
||||
|
||||
@property
|
||||
def work_item_state(self) -> WorkItemState:
|
||||
"""Return the work item state."""
|
||||
return self.work_item_type.state_items[self.state_key]
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | StateType:
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self.work_item_state)
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
},
|
||||
"url": {
|
||||
"name": "{definition_name} latest build url"
|
||||
},
|
||||
"work_item_count": {
|
||||
"name": "{item_type} {item_state} work items"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
from pyblu import Player, SyncStatus
|
||||
from pyblu.errors import PlayerUnreachableError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
@@ -22,14 +22,14 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluesoundData:
|
||||
class BluesoundRuntimeData:
|
||||
"""Bluesound data class."""
|
||||
|
||||
player: Player
|
||||
sync_status: SyncStatus
|
||||
|
||||
|
||||
type BluesoundConfigEntry = ConfigEntry[BluesoundData]
|
||||
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -51,14 +51,10 @@ async def async_setup_entry(
|
||||
async with Player(host, port, session=session, default_timeout=10) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timeout while connecting to {host}:{port}"
|
||||
) from ex
|
||||
except aiohttp.ClientError as ex:
|
||||
except PlayerUnreachableError as ex:
|
||||
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
|
||||
|
||||
config_entry.runtime_data = BluesoundData(player, sync_status)
|
||||
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pyblu import Player, SyncStatus
|
||||
from pyblu.errors import PlayerUnreachableError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
@@ -43,7 +43,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
except PlayerUnreachableError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
@@ -79,7 +79,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
except PlayerUnreachableError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(
|
||||
@@ -105,7 +105,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
discovery_info.host, self._port, session=session
|
||||
) as player:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
except PlayerUnreachableError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port))
|
||||
@@ -127,7 +127,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the zeroconf setup."""
|
||||
assert self._sync_status is not None
|
||||
assert self._host is not None
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyblu==0.4.0"],
|
||||
"requirements": ["pyblu==1.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_musc._tcp.local."
|
||||
|
||||
@@ -9,8 +9,8 @@ from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||
from pyblu.errors import PlayerUnreachableError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
@@ -239,7 +239,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
self.port = port
|
||||
self._polling_task: Task[None] | None = None # The actual polling task.
|
||||
self._id = sync_status.id
|
||||
self._last_status_update = None
|
||||
self._last_status_update: datetime | None = None
|
||||
self._sync_status = sync_status
|
||||
self._status: Status | None = None
|
||||
self._inputs: list[Input] = []
|
||||
@@ -247,7 +247,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
self._muted = False
|
||||
self._master: BluesoundPlayer | None = None
|
||||
self._is_master = False
|
||||
self._group_name = None
|
||||
self._group_name: str | None = None
|
||||
self._group_list: list[str] = []
|
||||
self._bluesound_device_name = sync_status.name
|
||||
self._player = player
|
||||
@@ -273,14 +273,6 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
via_device=(DOMAIN, format_mac(sync_status.mac)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_get_index(string, search_string):
|
||||
"""Get the index."""
|
||||
try:
|
||||
return string.index(search_string)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
async def force_update_sync_status(self) -> bool:
|
||||
"""Update the internal status."""
|
||||
sync_status = await self._player.sync_status()
|
||||
@@ -309,12 +301,12 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
return True
|
||||
|
||||
async def _poll_loop(self):
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Loop which polls the status of the player."""
|
||||
while True:
|
||||
try:
|
||||
await self.async_update_status()
|
||||
except (TimeoutError, ClientError):
|
||||
except PlayerUnreachableError:
|
||||
_LOGGER.error(
|
||||
"Node %s:%s is offline, retrying later", self.host, self.port
|
||||
)
|
||||
@@ -324,9 +316,9 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
"Stopping the polling of node %s:%s", self.host, self.port
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
except: # noqa: E722 - this loop should never stop
|
||||
_LOGGER.exception(
|
||||
"Unexpected error in %s:%s, retrying later", self.host, self.port
|
||||
"Unexpected error for %s:%s, retrying later", self.host, self.port
|
||||
)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
|
||||
@@ -356,12 +348,12 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if not self.available:
|
||||
return
|
||||
|
||||
with suppress(TimeoutError):
|
||||
with suppress(PlayerUnreachableError):
|
||||
await self.async_update_sync_status()
|
||||
await self.async_update_presets()
|
||||
await self.async_update_captures()
|
||||
|
||||
async def async_update_status(self):
|
||||
async def async_update_status(self) -> None:
|
||||
"""Use the poll session to always get the status of the player."""
|
||||
etag = None
|
||||
if self._status is not None:
|
||||
@@ -394,11 +386,11 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
# the device is playing. This would solve a lot of
|
||||
# problems. This change will be done when the
|
||||
# communication is moved to a separate library
|
||||
with suppress(TimeoutError):
|
||||
with suppress(PlayerUnreachableError):
|
||||
await self.force_update_sync_status()
|
||||
|
||||
self.async_write_ha_state()
|
||||
except (TimeoutError, ClientError):
|
||||
except PlayerUnreachableError:
|
||||
self._attr_available = False
|
||||
self._last_status_update = None
|
||||
self._status = None
|
||||
@@ -409,7 +401,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
)
|
||||
raise
|
||||
|
||||
async def async_trigger_sync_on_all(self):
|
||||
async def async_trigger_sync_on_all(self) -> None:
|
||||
"""Trigger sync status update on all devices."""
|
||||
_LOGGER.debug("Trigger sync status on all devices")
|
||||
|
||||
@@ -417,7 +409,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
await player.force_update_sync_status()
|
||||
|
||||
@Throttle(SYNC_STATUS_INTERVAL)
|
||||
async def async_update_sync_status(self):
|
||||
async def async_update_sync_status(self) -> None:
|
||||
"""Update sync status."""
|
||||
await self.force_update_sync_status()
|
||||
|
||||
@@ -506,8 +498,6 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
return None
|
||||
|
||||
position = self._status.seconds
|
||||
if position is None:
|
||||
return None
|
||||
|
||||
if mediastate == MediaPlayerState.PLAYING:
|
||||
position += (dt_util.utcnow() - self._last_status_update).total_seconds()
|
||||
@@ -524,7 +514,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if duration is None:
|
||||
return None
|
||||
|
||||
return duration
|
||||
return int(duration)
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
@@ -660,7 +650,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
return shuffle
|
||||
|
||||
async def async_join(self, master):
|
||||
async def async_join(self, master: str) -> None:
|
||||
"""Join the player to a group."""
|
||||
master_device = [
|
||||
device
|
||||
@@ -711,7 +701,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if entity.bluesound_device_name in device_group
|
||||
]
|
||||
|
||||
async def async_unjoin(self):
|
||||
async def async_unjoin(self) -> None:
|
||||
"""Unjoin the player from a group."""
|
||||
if self._master is None:
|
||||
return
|
||||
@@ -719,11 +709,11 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
_LOGGER.debug("Trying to unjoin player: %s", self.id)
|
||||
await self._master.async_remove_slave(self)
|
||||
|
||||
async def async_add_slave(self, slave_device: BluesoundPlayer):
|
||||
async def async_add_slave(self, slave_device: BluesoundPlayer) -> None:
|
||||
"""Add slave to master."""
|
||||
await self._player.add_slave(slave_device.host, slave_device.port)
|
||||
|
||||
async def async_remove_slave(self, slave_device: BluesoundPlayer):
|
||||
async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None:
|
||||
"""Remove slave to master."""
|
||||
await self._player.remove_slave(slave_device.host, slave_device.port)
|
||||
|
||||
@@ -731,7 +721,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
"""Increase sleep time on player."""
|
||||
return await self._player.sleep_timer()
|
||||
|
||||
async def async_clear_timer(self):
|
||||
async def async_clear_timer(self) -> None:
|
||||
"""Clear sleep timer on player."""
|
||||
sleep = 1
|
||||
while sleep > 0:
|
||||
@@ -755,6 +745,9 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if preset.name == source:
|
||||
url = preset.url
|
||||
|
||||
if url is None:
|
||||
raise ServiceValidationError(f"Source {source} not found")
|
||||
|
||||
await self._player.play_url(url)
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
@@ -826,20 +819,20 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
return None
|
||||
return
|
||||
|
||||
new_volume = self.volume_level + 0.01
|
||||
new_volume = min(1, new_volume)
|
||||
return await self.async_set_volume_level(new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
if self.volume_level is None:
|
||||
return None
|
||||
return
|
||||
|
||||
new_volume = self.volume_level - 0.01
|
||||
new_volume = max(0, new_volume)
|
||||
return await self.async_set_volume_level(new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
|
||||
@@ -5,7 +5,7 @@ import errno
|
||||
from functools import partial
|
||||
import logging
|
||||
import socket
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import broadlink as blk
|
||||
from broadlink.exceptions import (
|
||||
@@ -37,9 +37,7 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Broadlink flow."""
|
||||
self.device: blk.Device | None = None
|
||||
device: blk.Device
|
||||
|
||||
async def async_set_device(
|
||||
self, device: blk.Device, raise_on_progress: bool = True
|
||||
@@ -131,8 +129,6 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return await self.async_step_auth()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self.device
|
||||
if device.mac == self.device.mac:
|
||||
await self.async_set_device(device, raise_on_progress=False)
|
||||
return await self.async_step_auth()
|
||||
@@ -158,10 +154,10 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_auth(self):
|
||||
async def async_step_auth(self) -> ConfigFlowResult:
|
||||
"""Authenticate to the device."""
|
||||
device = self.device
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(device.auth)
|
||||
@@ -211,7 +207,11 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return self.async_show_form(step_id="auth", errors=errors)
|
||||
|
||||
async def async_step_reset(self, user_input=None, errors=None):
|
||||
async def async_step_reset(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
errors: dict[str, str] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Guide the user to unlock the device manually.
|
||||
|
||||
We are unable to authenticate because the device is locked.
|
||||
@@ -234,7 +234,9 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
{CONF_HOST: device.host[0], CONF_TIMEOUT: device.timeout}
|
||||
)
|
||||
|
||||
async def async_step_unlock(self, user_input=None):
|
||||
async def async_step_unlock(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Unlock the device.
|
||||
|
||||
The authentication succeeded, but the device is locked.
|
||||
@@ -288,10 +290,12 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_finish(self, user_input=None):
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose a name for the device and create config entry."""
|
||||
device = self.device
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
# Abort reauthentication flow.
|
||||
self._abort_if_unique_id_configured(
|
||||
|
||||
@@ -20,5 +20,8 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"info": data.info.to_dict(),
|
||||
"device": data.device.to_dict(),
|
||||
"state": data.coordinator.data.state.to_dict(),
|
||||
"coordinator_data": {
|
||||
"state": data.coordinator.data.state.to_dict(),
|
||||
},
|
||||
"static": data.static.to_dict(),
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ class BSBLanEntity(CoordinatorEntity[BSBLanUpdateCoordinator]):
|
||||
def __init__(self, coordinator: BSBLanUpdateCoordinator, data: BSBLanData) -> None:
|
||||
"""Initialize BSBLan entity."""
|
||||
super().__init__(coordinator, data)
|
||||
host = self.coordinator.config_entry.data["host"]
|
||||
mac = self.coordinator.config_entry.data["mac"]
|
||||
host = coordinator.config_entry.data["host"]
|
||||
mac = data.device.MAC
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, data.device.MAC)},
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
|
||||
name=data.device.name,
|
||||
manufacturer="BSBLAN Inc.",
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
|
||||
from homeassistant.components.device_automation.exceptions import (
|
||||
InvalidDeviceAutomationConfig,
|
||||
)
|
||||
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
@@ -31,7 +34,7 @@ from .const import (
|
||||
EVENT_TYPE,
|
||||
)
|
||||
|
||||
TRIGGERS_BY_EVENT_CLASS = {
|
||||
EVENT_TYPES_BY_EVENT_CLASS = {
|
||||
EVENT_CLASS_BUTTON: {
|
||||
"press",
|
||||
"double_press",
|
||||
@@ -43,54 +46,71 @@ TRIGGERS_BY_EVENT_CLASS = {
|
||||
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
|
||||
}
|
||||
|
||||
SCHEMA_BY_EVENT_CLASS = {
|
||||
EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]),
|
||||
vol.Required(CONF_SUBTYPE): vol.In(
|
||||
TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_BUTTON]
|
||||
),
|
||||
}
|
||||
),
|
||||
EVENT_CLASS_DIMMER: DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_DIMMER]),
|
||||
vol.Required(CONF_SUBTYPE): vol.In(
|
||||
TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_DIMMER]
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
|
||||
)
|
||||
|
||||
|
||||
def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[str]:
|
||||
"""Get the supported event classes for a device.
|
||||
|
||||
Events for BTHome BLE devices are dynamically discovered
|
||||
and stored in the device config entry when they are first seen.
|
||||
"""
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
if TYPE_CHECKING:
|
||||
assert device is not None
|
||||
|
||||
config_entries = [
|
||||
hass.config_entries.async_get_entry(entry_id)
|
||||
for entry_id in device.config_entries
|
||||
]
|
||||
bthome_config_entry = next(
|
||||
entry for entry in config_entries if entry and entry.domain == DOMAIN
|
||||
)
|
||||
return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])
|
||||
|
||||
|
||||
def get_event_types_by_event_class(event_class: str) -> set[str]:
|
||||
"""Get the supported event types for an event class.
|
||||
|
||||
If the device has multiple buttons they will have
|
||||
event classes like button_1 button_2, button_3, etc
|
||||
but if there is only one button then it will be
|
||||
button without a number postfix.
|
||||
"""
|
||||
return EVENT_TYPES_BY_EVENT_CLASS.get(event_class.split("_")[0], set())
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate trigger config."""
|
||||
return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return]
|
||||
config
|
||||
)
|
||||
config = TRIGGER_SCHEMA(config)
|
||||
event_class = config[CONF_TYPE]
|
||||
event_type = config[CONF_SUBTYPE]
|
||||
device_id = config[CONF_DEVICE_ID]
|
||||
event_classes = get_event_classes_by_device_id(hass, device_id)
|
||||
|
||||
if event_class not in event_classes:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"BTHome trigger {event_class} is not valid for device_id '{device_id}'"
|
||||
)
|
||||
|
||||
if event_type not in get_event_types_by_event_class(event_class):
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"BTHome trigger {event_type} is not valid for device_id '{device_id}'"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a list of triggers for BTHome BLE devices."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
assert device is not None
|
||||
config_entries = [
|
||||
hass.config_entries.async_get_entry(entry_id)
|
||||
for entry_id in device.config_entries
|
||||
]
|
||||
bthome_config_entry = next(
|
||||
iter(entry for entry in config_entries if entry and entry.domain == DOMAIN),
|
||||
None,
|
||||
)
|
||||
assert bthome_config_entry is not None
|
||||
event_classes: list[str] = bthome_config_entry.data.get(
|
||||
CONF_DISCOVERED_EVENT_CLASSES, []
|
||||
)
|
||||
event_classes = get_event_classes_by_device_id(hass, device_id)
|
||||
return [
|
||||
{
|
||||
# Required fields of TRIGGER_BASE_SCHEMA
|
||||
@@ -102,14 +122,7 @@ async def async_get_triggers(
|
||||
CONF_SUBTYPE: event_type,
|
||||
}
|
||||
for event_class in event_classes
|
||||
for event_type in TRIGGERS_BY_EVENT_CLASS.get(
|
||||
event_class.split("_")[0],
|
||||
# If the device has multiple buttons they will have
|
||||
# event classes like button_1 button_2, button_3, etc
|
||||
# but if there is only one button then it will be
|
||||
# button without a number postfix.
|
||||
(),
|
||||
)
|
||||
for event_type in get_event_types_by_event_class(event_class)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@Ernst79"],
|
||||
"codeowners": ["@Ernst79", "@thecode"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
|
||||
@@ -63,7 +63,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_config(self, user_input=None):
|
||||
async def async_step_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup."""
|
||||
errors = {}
|
||||
data = {CONF_KNOWN_HOSTS: self._known_hosts}
|
||||
@@ -90,7 +92,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="config", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(self, user_input=None):
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup."""
|
||||
|
||||
data = self._get_data()
|
||||
@@ -116,13 +120,15 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
self.config_entry = config_entry
|
||||
self.updated_config: dict[str, Any] = {}
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
return await self.async_step_basic_options()
|
||||
|
||||
async def async_step_basic_options(self, user_input=None):
|
||||
async def async_step_basic_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
current_config = self.config_entry.data
|
||||
if user_input is not None:
|
||||
bad_hosts, known_hosts = _string_to_list(
|
||||
@@ -139,9 +145,9 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self.updated_config
|
||||
)
|
||||
return self.async_create_entry(title="", data=None)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
fields = {}
|
||||
fields: dict[vol.Marker, type[str]] = {}
|
||||
suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS))
|
||||
_add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value)
|
||||
|
||||
@@ -152,9 +158,11 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
last_step=not self.show_advanced_options,
|
||||
)
|
||||
|
||||
async def async_step_advanced_options(self, user_input=None):
|
||||
async def async_step_advanced_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Google Cast options."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
bad_cec, ignore_cec = _string_to_list(
|
||||
user_input.get(CONF_IGNORE_CEC, ""), IGNORE_CEC_SCHEMA
|
||||
@@ -169,9 +177,9 @@ class CastOptionsFlowHandler(OptionsFlow):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=self.updated_config
|
||||
)
|
||||
return self.async_create_entry(title="", data=None)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
fields = {}
|
||||
fields: dict[vol.Marker, type[str]] = {}
|
||||
current_config = self.config_entry.data
|
||||
suggested_value = _list_to_string(current_config.get(CONF_UUID))
|
||||
_add_with_suggestion(fields, CONF_UUID, suggested_value)
|
||||
@@ -204,5 +212,7 @@ def _string_to_list(string, schema):
|
||||
return invalid, items
|
||||
|
||||
|
||||
def _add_with_suggestion(fields, key, suggested_value):
|
||||
def _add_with_suggestion(
|
||||
fields: dict[vol.Marker, type[str]], key: str, suggested_value: str
|
||||
) -> None:
|
||||
fields[vol.Optional(key, description={"suggested_value": suggested_value})] = str
|
||||
|
||||
@@ -429,7 +429,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
(
|
||||
"%s::%s implements the `is_aux_heat` property or uses the auxiliary "
|
||||
"heater methods in a subclass of ClimateEntity which is "
|
||||
"deprecated and will be unsupported from Home Assistant 2024.10."
|
||||
"deprecated and will be unsupported from Home Assistant 2025.4."
|
||||
" Please %s"
|
||||
),
|
||||
self.platform.platform_name,
|
||||
@@ -451,7 +451,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_climate_aux_{self.platform.platform_name}",
|
||||
breaks_in_ha_version="2024.10.0",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=self.platform.platform_name,
|
||||
|
||||
@@ -131,16 +131,23 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the reauth step."""
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
}
|
||||
)
|
||||
return await self._validate_and_create("reauth", data_schema, entry_data)
|
||||
return await self._validate_and_create(
|
||||
"reauth_confirm", data_schema, user_input
|
||||
)
|
||||
|
||||
async def _validate_and_create(
|
||||
self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any]
|
||||
self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Validate data and show form if it is invalid."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"country_code": "Country code"
|
||||
}
|
||||
},
|
||||
"reauth": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::access_token%]"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from pyControl4.account import C4Account
|
||||
@@ -23,7 +23,7 @@ from homeassistant.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -49,7 +49,9 @@ DATA_SCHEMA = vol.Schema(
|
||||
class Control4Validator:
|
||||
"""Validates that config details can be used to authenticate and communicate with Control4."""
|
||||
|
||||
def __init__(self, host, username, password, hass):
|
||||
def __init__(
|
||||
self, host: str, username: str, password: str, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.host = host
|
||||
self.username = username
|
||||
@@ -126,6 +128,8 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if not errors:
|
||||
controller_unique_id = hub.controller_unique_id
|
||||
if TYPE_CHECKING:
|
||||
assert hub.controller_unique_id
|
||||
mac = (controller_unique_id.split("_", 3))[2]
|
||||
formatted_mac = format_mac(mac)
|
||||
await self.async_set_unique_id(formatted_mac)
|
||||
@@ -160,7 +164,9 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.29"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.9.4"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""The deako integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout
|
||||
from pydeako.discover import DeakoDiscoverer
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
type DeakoConfigEntry = ConfigEntry[Deako]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool:
|
||||
"""Set up deako."""
|
||||
_zc = await zeroconf.async_get_instance(hass)
|
||||
discoverer = DeakoDiscoverer(_zc)
|
||||
|
||||
connection = Deako(discoverer.get_address)
|
||||
|
||||
await connection.connect()
|
||||
try:
|
||||
await connection.find_devices()
|
||||
except DeviceListTimeout as exc: # device list never received
|
||||
_LOGGER.warning("Device not responding to device list")
|
||||
await connection.disconnect()
|
||||
raise ConfigEntryNotReady(exc) from exc
|
||||
except FindDevicesTimeout as exc: # total devices expected not received
|
||||
_LOGGER.warning("Device not responding to device requests")
|
||||
await connection.disconnect()
|
||||
raise ConfigEntryNotReady(exc) from exc
|
||||
|
||||
# If deako devices are advertising on mdns, we should be able to get at least one device
|
||||
devices = connection.get_devices()
|
||||
if len(devices) == 0:
|
||||
await connection.disconnect()
|
||||
raise ConfigEntryNotReady(devices)
|
||||
|
||||
entry.runtime_data = connection
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.disconnect()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Config flow for deako."""
|
||||
|
||||
from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_flow
|
||||
|
||||
from .const import DOMAIN, NAME
|
||||
|
||||
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
_zc = await zeroconf.async_get_instance(hass)
|
||||
discoverer = DeakoDiscoverer(_zc)
|
||||
|
||||
try:
|
||||
await discoverer.get_address()
|
||||
except DevicesNotFoundException:
|
||||
return False
|
||||
else:
|
||||
# address exists, there's at least one device
|
||||
return True
|
||||
|
||||
|
||||
config_entry_flow.register_discovery_flow(DOMAIN, NAME, _async_has_devices)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Constants for Deako."""
|
||||
|
||||
# Base component constants
|
||||
NAME = "Deako"
|
||||
DOMAIN = "deako"
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Binary sensor platform for integration_blueprint."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydeako.deako import Deako
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DeakoConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
# Model names
|
||||
MODEL_SMART = "smart"
|
||||
MODEL_DIMMER = "dimmer"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: DeakoConfigEntry,
|
||||
add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Configure the platform."""
|
||||
client = config.runtime_data
|
||||
|
||||
add_entities([DeakoLightEntity(client, uuid) for uuid in client.get_devices()])
|
||||
|
||||
|
||||
class DeakoLightEntity(LightEntity):
|
||||
"""Deako LightEntity class."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_is_on = False
|
||||
_attr_available = True
|
||||
|
||||
client: Deako
|
||||
|
||||
def __init__(self, client: Deako, uuid: str) -> None:
|
||||
"""Save connection reference."""
|
||||
self.client = client
|
||||
self._attr_unique_id = uuid
|
||||
|
||||
dimmable = client.is_dimmable(uuid)
|
||||
|
||||
model = MODEL_SMART
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
if dimmable:
|
||||
model = MODEL_DIMMER
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
|
||||
self._attr_supported_color_modes = {self._attr_color_mode}
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, uuid)},
|
||||
name=client.get_name(uuid),
|
||||
manufacturer="Deako",
|
||||
model=model,
|
||||
)
|
||||
|
||||
client.set_state_callback(uuid, self.on_update)
|
||||
self.update() # set initial state
|
||||
|
||||
def on_update(self) -> None:
|
||||
"""State update callback."""
|
||||
self.update()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def control_device(self, power: bool, dim: int | None = None) -> None:
|
||||
"""Control entity state via client."""
|
||||
assert self._attr_unique_id is not None
|
||||
await self.client.control_device(self._attr_unique_id, power, dim)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
dim = None
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
dim = round(kwargs[ATTR_BRIGHTNESS] / 2.55, 0)
|
||||
await self.control_device(True, dim)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
await self.control_device(False)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Call to update state."""
|
||||
assert self._attr_unique_id is not None
|
||||
state = self.client.get_state(self._attr_unique_id) or {}
|
||||
self._attr_is_on = bool(state.get("power", False))
|
||||
if (
|
||||
self._attr_supported_color_modes is not None
|
||||
and ColorMode.BRIGHTNESS in self._attr_supported_color_modes
|
||||
):
|
||||
self._attr_brightness = int(round(state.get("dim", 0) * 2.55))
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "deako",
|
||||
"name": "Deako",
|
||||
"codeowners": ["@sebirdman", "@balake", "@deakolights"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["zeroconf"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/deako",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydeako"],
|
||||
"requirements": ["pydeako==0.4.0"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_deako._tcp.local."]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Please confirm setting up the Deako integration"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ class DexcomOptionsFlowHandler(OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
@@ -70,8 +70,16 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
self._existing_entry = await self.async_set_unique_id(self.context["unique_id"])
|
||||
return await self._validate_and_save(entry_data, step_id="reauth")
|
||||
self._existing_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the reauth step."""
|
||||
return await self._validate_and_save(user_input, step_id="reauth_confirm")
|
||||
|
||||
async def _validate_and_save(
|
||||
self, user_input: Mapping[str, Any] | None = None, step_id: str = "user"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"reauth": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
|
||||
@@ -23,9 +23,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the ecobee flow."""
|
||||
self._ecobee: Ecobee | None = None
|
||||
_ecobee: Ecobee
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -59,7 +57,9 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_authorize(self, user_input=None):
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Present the user with the PIN so that the app can be authorized on ecobee.com."""
|
||||
errors = {}
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==8.3.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==8.4.0"]
|
||||
}
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
"""The emoncms component."""
|
||||
|
||||
from pyemoncms import EmoncmsClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import EmoncmsCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
|
||||
"""Load a config entry."""
|
||||
emoncms_client = EmoncmsClient(
|
||||
entry.data[CONF_URL],
|
||||
entry.data[CONF_API_KEY],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
coordinator = EmoncmsCoordinator(hass, emoncms_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Configflow for the emoncms integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyemoncms import EmoncmsClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithConfigEntry,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import selector
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_MESSAGE,
|
||||
CONF_ONLY_INCLUDE_FEEDID,
|
||||
CONF_SUCCESS,
|
||||
DOMAIN,
|
||||
FEED_ID,
|
||||
FEED_NAME,
|
||||
FEED_TAG,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
def get_options(feeds: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Build the selector options with the feed list."""
|
||||
return [
|
||||
{
|
||||
"value": feed[FEED_ID],
|
||||
"label": f"{feed[FEED_ID]}|{feed[FEED_TAG]}|{feed[FEED_NAME]}",
|
||||
}
|
||||
for feed in feeds
|
||||
]
|
||||
|
||||
|
||||
def sensor_name(url: str) -> str:
|
||||
"""Return sensor name."""
|
||||
sensorip = url.rsplit("//", maxsplit=1)[-1]
|
||||
return f"emoncms@{sensorip}"
|
||||
|
||||
|
||||
async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]:
|
||||
"""Check connection to emoncms and return feed list if successful."""
|
||||
emoncms_client = EmoncmsClient(
|
||||
url,
|
||||
api_key,
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
return await emoncms_client.async_request("/feed/list.json")
|
||||
|
||||
|
||||
class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""emoncms integration UI config flow."""
|
||||
|
||||
url: str
|
||||
api_key: str
|
||||
include_only_feeds: list | None = None
|
||||
dropdown: dict = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowWithConfigEntry:
|
||||
"""Get the options flow for this handler."""
|
||||
return EmoncmsOptionsFlow(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Initiate a flow via the UI."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
}
|
||||
)
|
||||
result = await get_feed_list(
|
||||
self.hass, user_input[CONF_URL], user_input[CONF_API_KEY]
|
||||
)
|
||||
if not result[CONF_SUCCESS]:
|
||||
errors["base"] = result[CONF_MESSAGE]
|
||||
else:
|
||||
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
|
||||
self.url = user_input[CONF_URL]
|
||||
self.api_key = user_input[CONF_API_KEY]
|
||||
options = get_options(result[CONF_MESSAGE])
|
||||
self.dropdown = {
|
||||
"options": options,
|
||||
"mode": "dropdown",
|
||||
"multiple": True,
|
||||
}
|
||||
return await self.async_step_choose_feeds()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_choose_feeds(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose feeds to import."""
|
||||
errors: dict[str, str] = {}
|
||||
include_only_feeds: list = []
|
||||
if user_input or self.include_only_feeds is not None:
|
||||
if self.include_only_feeds is not None:
|
||||
include_only_feeds = self.include_only_feeds
|
||||
elif user_input:
|
||||
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
|
||||
return self.async_create_entry(
|
||||
title=sensor_name(self.url),
|
||||
data={
|
||||
CONF_URL: self.url,
|
||||
CONF_API_KEY: self.api_key,
|
||||
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
|
||||
},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="choose_feeds",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ONLY_INCLUDE_FEEDID,
|
||||
default=include_only_feeds,
|
||||
): selector({"select": self.dropdown}),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult:
|
||||
"""Import config from yaml."""
|
||||
url = import_info[CONF_URL]
|
||||
api_key = import_info[CONF_API_KEY]
|
||||
include_only_feeds = None
|
||||
if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None:
|
||||
include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID]))
|
||||
config = {
|
||||
CONF_API_KEY: api_key,
|
||||
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
|
||||
CONF_URL: url,
|
||||
}
|
||||
LOGGER.debug(config)
|
||||
result = await self.async_step_user(config)
|
||||
if errors := result.get("errors"):
|
||||
return self.async_abort(reason=errors["base"])
|
||||
return result
|
||||
|
||||
|
||||
class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry):
|
||||
"""Emoncms Options flow handler."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
data = self.options if self.options else self._config_entry.data
|
||||
url = data[CONF_URL]
|
||||
api_key = data[CONF_API_KEY]
|
||||
include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, [])
|
||||
options: list = include_only_feeds
|
||||
result = await get_feed_list(self.hass, url, api_key)
|
||||
if not result[CONF_SUCCESS]:
|
||||
errors["base"] = result[CONF_MESSAGE]
|
||||
else:
|
||||
options = get_options(result[CONF_MESSAGE])
|
||||
dropdown = {"options": options, "mode": "dropdown", "multiple": True}
|
||||
if user_input:
|
||||
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
|
||||
return self.async_create_entry(
|
||||
title=sensor_name(url),
|
||||
data={
|
||||
CONF_URL: url,
|
||||
CONF_API_KEY: api_key,
|
||||
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ONLY_INCLUDE_FEEDID, default=include_only_feeds
|
||||
): selector({"select": dropdown}),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -7,6 +7,9 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
|
||||
CONF_MESSAGE = "message"
|
||||
CONF_SUCCESS = "success"
|
||||
DOMAIN = "emoncms"
|
||||
FEED_ID = "id"
|
||||
FEED_NAME = "name"
|
||||
FEED_TAG = "tag"
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
@@ -18,14 +18,13 @@ class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
emoncms_client: EmoncmsClient,
|
||||
scan_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the emoncms data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name="emoncms_coordinator",
|
||||
update_interval=scan_interval,
|
||||
update_interval=timedelta(seconds=60),
|
||||
)
|
||||
self.emoncms_client = emoncms_client
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "emoncms",
|
||||
"name": "Emoncms",
|
||||
"codeowners": ["@borpin", "@alexandrecuer"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.0.7"]
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyemoncms import EmoncmsClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -14,25 +12,33 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_ID,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_URL,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID
|
||||
from .config_flow import sensor_name
|
||||
from .const import (
|
||||
CONF_EXCLUDE_FEEDID,
|
||||
CONF_ONLY_INCLUDE_FEEDID,
|
||||
DOMAIN,
|
||||
FEED_ID,
|
||||
FEED_NAME,
|
||||
FEED_TAG,
|
||||
)
|
||||
from .coordinator import EmoncmsCoordinator
|
||||
|
||||
ATTR_FEEDID = "FeedId"
|
||||
@@ -42,9 +48,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr"
|
||||
ATTR_SIZE = "Size"
|
||||
ATTR_TAG = "Tag"
|
||||
ATTR_USERID = "UserId"
|
||||
|
||||
CONF_SENSOR_NAMES = "sensor_names"
|
||||
|
||||
DECIMALS = 2
|
||||
DEFAULT_UNIT = UnitOfPower.WATT
|
||||
|
||||
@@ -76,20 +80,73 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Emoncms sensor."""
|
||||
apikey = config[CONF_API_KEY]
|
||||
url = config[CONF_URL]
|
||||
sensorid = config[CONF_ID]
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
config_unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
"""Import config from yaml."""
|
||||
if CONF_VALUE_TEMPLATE in config:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key=f"remove_{CONF_VALUE_TEMPLATE}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"parameter": CONF_VALUE_TEMPLATE,
|
||||
},
|
||||
)
|
||||
return
|
||||
if CONF_ONLY_INCLUDE_FEEDID not in config:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
if (
|
||||
result.get("type") == FlowResultType.CREATE_ENTRY
|
||||
or result.get("reason") == "already_configured"
|
||||
):
|
||||
async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
breaks_in_ha_version="2025.3.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "emoncms",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the emoncms sensors."""
|
||||
config = entry.options if entry.options else entry.data
|
||||
name = sensor_name(config[CONF_URL])
|
||||
exclude_feeds = config.get(CONF_EXCLUDE_FEEDID)
|
||||
include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID)
|
||||
sensor_names = config.get(CONF_SENSOR_NAMES)
|
||||
scan_interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=30))
|
||||
|
||||
emoncms_client = EmoncmsClient(url, apikey, session=async_get_clientsession(hass))
|
||||
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval)
|
||||
await coordinator.async_refresh()
|
||||
if exclude_feeds is None and include_only_feeds is None:
|
||||
return
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
elems = coordinator.data
|
||||
if not elems:
|
||||
return
|
||||
@@ -97,28 +154,15 @@ async def async_setup_platform(
|
||||
sensors: list[EmonCmsSensor] = []
|
||||
|
||||
for idx, elem in enumerate(elems):
|
||||
if exclude_feeds is not None and int(elem["id"]) in exclude_feeds:
|
||||
if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
|
||||
continue
|
||||
|
||||
if include_only_feeds is not None and int(elem["id"]) not in include_only_feeds:
|
||||
continue
|
||||
|
||||
name = None
|
||||
if sensor_names is not None:
|
||||
name = sensor_names.get(int(elem["id"]), None)
|
||||
|
||||
if unit := elem.get("unit"):
|
||||
unit_of_measurement = unit
|
||||
else:
|
||||
unit_of_measurement = config_unit
|
||||
|
||||
sensors.append(
|
||||
EmonCmsSensor(
|
||||
coordinator,
|
||||
entry.entry_id,
|
||||
elem["unit"],
|
||||
name,
|
||||
value_template,
|
||||
unit_of_measurement,
|
||||
str(sensorid),
|
||||
idx,
|
||||
)
|
||||
)
|
||||
@@ -131,10 +175,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EmoncmsCoordinator,
|
||||
name: str | None,
|
||||
value_template: template.Template | None,
|
||||
entry_id: str,
|
||||
unit_of_measurement: str | None,
|
||||
sensorid: str,
|
||||
name: str,
|
||||
idx: int,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -143,20 +186,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
||||
elem = {}
|
||||
if self.coordinator.data:
|
||||
elem = self.coordinator.data[self.idx]
|
||||
if name is None:
|
||||
# Suppress ID in sensor name if it's 1, since most people won't
|
||||
# have more than one EmonCMS source and it's redundant to show the
|
||||
# ID if there's only one.
|
||||
id_for_name = "" if str(sensorid) == "1" else sensorid
|
||||
# Use the feed name assigned in EmonCMS or fall back to the feed ID
|
||||
feed_name = elem.get("name", f"Feed {elem.get('id')}")
|
||||
self._attr_name = f"EmonCMS{id_for_name} {feed_name}"
|
||||
else:
|
||||
self._attr_name = name
|
||||
self._value_template = value_template
|
||||
self._attr_name = f"{name} {elem[FEED_NAME]}"
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._sensorid = sensorid
|
||||
|
||||
self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}"
|
||||
if unit_of_measurement in ("kWh", "Wh"):
|
||||
self._attr_device_class = SensorDeviceClass.ENERGY
|
||||
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
@@ -186,9 +218,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
||||
def _update_attributes(self, elem: dict[str, Any]) -> None:
|
||||
"""Update entity attributes."""
|
||||
self._attr_extra_state_attributes = {
|
||||
ATTR_FEEDID: elem["id"],
|
||||
ATTR_TAG: elem["tag"],
|
||||
ATTR_FEEDNAME: elem["name"],
|
||||
ATTR_FEEDID: elem[FEED_ID],
|
||||
ATTR_TAG: elem[FEED_TAG],
|
||||
ATTR_FEEDNAME: elem[FEED_NAME],
|
||||
}
|
||||
if elem["value"] is not None:
|
||||
self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"]
|
||||
@@ -199,13 +231,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
||||
)
|
||||
|
||||
self._attr_native_value = None
|
||||
if self._value_template is not None:
|
||||
self._attr_native_value = (
|
||||
self._value_template.async_render_with_possible_json_value(
|
||||
elem["value"], STATE_UNKNOWN
|
||||
)
|
||||
)
|
||||
elif elem["value"] is not None:
|
||||
if elem["value"] is not None:
|
||||
self._attr_native_value = round(float(elem["value"]), DECIMALS)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Server url starting with the protocol (http or https)",
|
||||
"api_key": "Your 32 bits api key"
|
||||
}
|
||||
},
|
||||
"choose_feeds": {
|
||||
"data": {
|
||||
"include_only_feed_id": "Choose feeds to include"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"remove_value_template": {
|
||||
"title": "The {domain} integration cannot start",
|
||||
"description": "Configuring {domain} using YAML is being removed and the `{parameter}` parameter cannot be imported.\n\nPlease remove `{parameter}` from your `{domain}` yaml configuration and restart Home Assistant\n\nAlternatively, you may entirely remove the `{domain}` configuration from your configuration.yaml, restart Home Assistant, and add the {domain} integration manually."
|
||||
},
|
||||
"missing_include_only_feed_id": {
|
||||
"title": "No feed synchronized with the {domain} sensor",
|
||||
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,11 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
discovered_info: dict[str, str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Emonitor ConfigFlow."""
|
||||
self.discovered_ip: str | None = None
|
||||
self.discovered_info: dict[str, str] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -87,7 +88,9 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_user()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None):
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt to confirm."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -6,13 +6,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import CONF_LISTEN_PORT, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def configured_servers(hass):
|
||||
def configured_servers(hass: HomeAssistant) -> set[str]:
|
||||
"""Return a set of the configured servers."""
|
||||
return {
|
||||
entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
@@ -43,12 +43,14 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_detect()
|
||||
|
||||
async def async_step_detect(self, user_input=None):
|
||||
async def async_step_detect(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Propose a list of detected dongles."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if user_input[CONF_DEVICE] == self.MANUAL_PATH_VALUE:
|
||||
return await self.async_step_manual(None)
|
||||
return await self.async_step_manual()
|
||||
if await self.validate_enocean_conf(user_input):
|
||||
return self.create_enocean_entry(user_input)
|
||||
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
|
||||
@@ -64,7 +66,9 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_manual(self, user_input=None):
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Request manual USB dongle path."""
|
||||
default_value = None
|
||||
errors = {}
|
||||
|
||||
@@ -119,7 +119,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity):
|
||||
self._partition_number = partition_number
|
||||
self._panic_type = panic_type
|
||||
self._alarm_control_panel_option_default_code = code
|
||||
self._attr_code_format = CodeFormat.NUMBER
|
||||
self._attr_code_format = CodeFormat.NUMBER if not code else None
|
||||
|
||||
_LOGGER.debug("Setting up alarm: %s", alarm_name)
|
||||
super().__init__(alarm_name, info, controller)
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, HTTP
|
||||
from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP
|
||||
from .exceptions import CannotConnect, PoweredOff
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
@@ -22,13 +22,17 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_projector(
|
||||
hass: HomeAssistant, host, check_power=True, check_powered_on=True
|
||||
hass: HomeAssistant,
|
||||
host: str,
|
||||
conn_type: str,
|
||||
check_power: bool = True,
|
||||
check_powered_on: bool = True,
|
||||
):
|
||||
"""Validate the given projector host allows us to connect."""
|
||||
epson_proj = Projector(
|
||||
host=host,
|
||||
websession=async_get_clientsession(hass, verify_ssl=False),
|
||||
type=HTTP,
|
||||
type=conn_type,
|
||||
)
|
||||
if check_power:
|
||||
_power = await epson_proj.get_power()
|
||||
@@ -46,6 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
projector = await validate_projector(
|
||||
hass=hass,
|
||||
host=entry.data[CONF_HOST],
|
||||
conn_type=entry.data[CONF_CONNECTION_TYPE],
|
||||
check_power=False,
|
||||
check_powered_on=False,
|
||||
)
|
||||
@@ -60,5 +65,33 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
projector = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
projector.close()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 1 or config_entry.minor_version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
new_data = {**config_entry.data}
|
||||
new_data[CONF_CONNECTION_TYPE] = HTTP
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=new_data, version=1, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s successful", config_entry.version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,13 +7,21 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from . import validate_projector
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP, SERIAL
|
||||
from .exceptions import CannotConnect, PoweredOff
|
||||
|
||||
ALLOWED_CONNECTION_TYPE = [HTTP, SERIAL]
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CONNECTION_TYPE, default=HTTP): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=ALLOWED_CONNECTION_TYPE, translation_key="connection_type"
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_NAME, default=DOMAIN): str,
|
||||
}
|
||||
@@ -26,6 +34,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for epson."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -33,12 +42,16 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# Epson projector doesn't appear to need to be on for serial
|
||||
check_power = user_input[CONF_CONNECTION_TYPE] != SERIAL
|
||||
projector = None
|
||||
try:
|
||||
projector = await validate_projector(
|
||||
hass=self.hass,
|
||||
conn_type=user_input[CONF_CONNECTION_TYPE],
|
||||
host=user_input[CONF_HOST],
|
||||
check_power=True,
|
||||
check_powered_on=True,
|
||||
check_powered_on=check_power,
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -55,6 +68,9 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title=user_input.pop(CONF_NAME), data=user_input
|
||||
)
|
||||
finally:
|
||||
if projector:
|
||||
projector.close()
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
DOMAIN = "epson"
|
||||
SERVICE_SELECT_CMODE = "select_cmode"
|
||||
CONF_CONNECTION_TYPE = "connection_type"
|
||||
|
||||
ATTR_CMODE = "cmode"
|
||||
HTTP = "http"
|
||||
SERIAL = "serial"
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"connection_type": "Connection type",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Epson projector."
|
||||
"host": "The hostname, IP address or serial port of your Epson projector."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -30,5 +31,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"connection_type": {
|
||||
"options": {
|
||||
"http": "HTTP",
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Support for assist satellites in ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from functools import partial
|
||||
import io
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, cast
|
||||
import wave
|
||||
|
||||
from aioesphomeapi import (
|
||||
VoiceAssistantAudioSettings,
|
||||
VoiceAssistantCommandFlag,
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
|
||||
from homeassistant.components import assist_satellite, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
PipelineEvent,
|
||||
PipelineEventType,
|
||||
PipelineStage,
|
||||
)
|
||||
from homeassistant.components.intent import async_register_timer_handler
|
||||
from homeassistant.components.intent.timers import TimerEventType, TimerInfo
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import EsphomeAssistEntity
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
|
||||
VoiceAssistantEventType, PipelineEventType
|
||||
] = EsphomeEnumMapper(
|
||||
{
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END,
|
||||
}
|
||||
)
|
||||
|
||||
_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = (
|
||||
EsphomeEnumMapper(
|
||||
{
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Assist satellite entity."""
|
||||
entry_data = entry.runtime_data
|
||||
assert entry_data.device_info is not None
|
||||
if entry_data.device_info.voice_assistant_feature_flags_compat(
|
||||
entry_data.api_version
|
||||
):
|
||||
async_add_entities(
|
||||
[
|
||||
EsphomeAssistSatellite(hass, entry, entry_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class EsphomeAssistSatellite(
|
||||
EsphomeAssistEntity, assist_satellite.AssistSatelliteEntity
|
||||
):
|
||||
"""Satellite running ESPHome."""
|
||||
|
||||
entity_description = assist_satellite.AssistSatelliteEntityDescription(
|
||||
key="assist_satellite",
|
||||
translation_key="assist_satellite",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry_data: RuntimeEntryData,
|
||||
) -> None:
|
||||
"""Initialize satellite."""
|
||||
super().__init__(entry_data)
|
||||
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.entry_data = entry_data
|
||||
self.cli = self.entry_data.client
|
||||
|
||||
self._is_running: bool = True
|
||||
self._pipeline_task: asyncio.Task | None = None
|
||||
self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||
self._tts_streaming_task: asyncio.Task | None = None
|
||||
self._udp_server: VoiceAssistantUDPServer | None = None
|
||||
|
||||
@property
|
||||
def pipeline_entity_id(self) -> str | None:
|
||||
"""Return the entity ID of the pipeline to use for the next conversation."""
|
||||
assert self.entry_data.device_info is not None
|
||||
ent_reg = er.async_get(self.hass)
|
||||
return ent_reg.async_get_entity_id(
|
||||
Platform.SELECT,
|
||||
DOMAIN,
|
||||
f"{self.entry_data.device_info.mac_address}-pipeline",
|
||||
)
|
||||
|
||||
@property
|
||||
def vad_sensitivity_entity_id(self) -> str | None:
|
||||
"""Return the entity ID of the VAD sensitivity to use for the next conversation."""
|
||||
assert self.entry_data.device_info is not None
|
||||
ent_reg = er.async_get(self.hass)
|
||||
return ent_reg.async_get_entity_id(
|
||||
Platform.SELECT,
|
||||
DOMAIN,
|
||||
f"{self.entry_data.device_info.mac_address}-vad_sensitivity",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
assert self.entry_data.device_info is not None
|
||||
feature_flags = (
|
||||
self.entry_data.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
)
|
||||
if feature_flags & VoiceAssistantFeature.API_AUDIO:
|
||||
# TCP audio
|
||||
self.entry_data.disconnect_callbacks.add(
|
||||
self.cli.subscribe_voice_assistant(
|
||||
handle_start=self.handle_pipeline_start,
|
||||
handle_stop=self.handle_pipeline_stop,
|
||||
handle_audio=self.handle_audio,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# UDP audio
|
||||
self.entry_data.disconnect_callbacks.add(
|
||||
self.cli.subscribe_voice_assistant(
|
||||
handle_start=self.handle_pipeline_start,
|
||||
handle_stop=self.handle_pipeline_stop,
|
||||
)
|
||||
)
|
||||
|
||||
if feature_flags & VoiceAssistantFeature.TIMERS:
|
||||
# Device supports timers
|
||||
assert (self.registry_entry is not None) and (
|
||||
self.registry_entry.device_id is not None
|
||||
)
|
||||
self.entry_data.disconnect_callbacks.add(
|
||||
async_register_timer_handler(
|
||||
self.hass, self.registry_entry.device_id, self.handle_timer_event
|
||||
)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
self._is_running = False
|
||||
self._stop_pipeline()
|
||||
|
||||
def on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
try:
|
||||
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Received unknown pipeline event type: %s", event.type)
|
||||
return
|
||||
|
||||
data_to_send: dict[str, Any] = {}
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START:
|
||||
self.entry_data.async_set_assist_pipeline_state(True)
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"conversation_id": event.data["intent_output"]["conversation_id"] or "",
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["tts_input"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
assert event.data is not None
|
||||
if tts_output := event.data["tts_output"]:
|
||||
path = tts_output["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
|
||||
assert self.entry_data.device_info is not None
|
||||
feature_flags = (
|
||||
self.entry_data.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
)
|
||||
if feature_flags & VoiceAssistantFeature.SPEAKER:
|
||||
media_id = tts_output["media_id"]
|
||||
self._tts_streaming_task = (
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._stream_tts_audio(media_id),
|
||||
"esphome_voice_assistant_tts",
|
||||
)
|
||||
)
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END:
|
||||
assert event.data is not None
|
||||
if not event.data["wake_word_output"]:
|
||||
event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR
|
||||
data_to_send = {
|
||||
"code": "no_wake_word",
|
||||
"message": "No wake word detected",
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"code": event.data["code"],
|
||||
"message": event.data["message"],
|
||||
}
|
||||
|
||||
self.cli.send_voice_assistant_event(event_type, data_to_send)
|
||||
|
||||
async def handle_pipeline_start(
|
||||
self,
|
||||
conversation_id: str,
|
||||
flags: int,
|
||||
audio_settings: VoiceAssistantAudioSettings,
|
||||
wake_word_phrase: str | None,
|
||||
) -> int | None:
|
||||
"""Handle pipeline run request."""
|
||||
# Clear audio queue
|
||||
while not self._audio_queue.empty():
|
||||
await self._audio_queue.get()
|
||||
|
||||
if self._tts_streaming_task is not None:
|
||||
# Cancel current TTS response
|
||||
self._tts_streaming_task.cancel()
|
||||
self._tts_streaming_task = None
|
||||
|
||||
# API or UDP output audio
|
||||
port: int = 0
|
||||
assert self.entry_data.device_info is not None
|
||||
feature_flags = (
|
||||
self.entry_data.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
)
|
||||
if (feature_flags & VoiceAssistantFeature.SPEAKER) and not (
|
||||
feature_flags & VoiceAssistantFeature.API_AUDIO
|
||||
):
|
||||
port = await self._start_udp_server()
|
||||
_LOGGER.debug("Started UDP server on port %s", port)
|
||||
|
||||
# Device triggered pipeline (wake word, etc.)
|
||||
if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD:
|
||||
start_stage = PipelineStage.WAKE_WORD
|
||||
else:
|
||||
start_stage = PipelineStage.STT
|
||||
|
||||
end_stage = PipelineStage.TTS
|
||||
|
||||
# Run the pipeline
|
||||
_LOGGER.debug("Running pipeline from %s to %s", start_stage, end_stage)
|
||||
self.entry_data.async_set_assist_pipeline_state(True)
|
||||
self._pipeline_task = self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self.async_accept_pipeline_from_satellite(
|
||||
audio_stream=self._wrap_audio_stream(),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
),
|
||||
"esphome_assist_satellite_pipeline",
|
||||
)
|
||||
self._pipeline_task.add_done_callback(
|
||||
lambda _future: self.handle_pipeline_finished()
|
||||
)
|
||||
|
||||
return port
|
||||
|
||||
async def handle_audio(self, data: bytes) -> None:
|
||||
"""Handle incoming audio chunk from API."""
|
||||
self._audio_queue.put_nowait(data)
|
||||
|
||||
async def handle_pipeline_stop(self) -> None:
|
||||
"""Handle request for pipeline to stop."""
|
||||
self._stop_pipeline()
|
||||
|
||||
def handle_pipeline_finished(self) -> None:
|
||||
"""Handle when pipeline has finished running."""
|
||||
self.entry_data.async_set_assist_pipeline_state(False)
|
||||
self._stop_udp_server()
|
||||
_LOGGER.debug("Pipeline finished")
|
||||
|
||||
def handle_timer_event(
|
||||
self, event_type: TimerEventType, timer_info: TimerInfo
|
||||
) -> None:
|
||||
"""Handle timer events."""
|
||||
try:
|
||||
native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Received unknown timer event type: %s", event_type)
|
||||
return
|
||||
|
||||
self.cli.send_voice_assistant_timer_event(
|
||||
native_event_type,
|
||||
timer_info.id,
|
||||
timer_info.name,
|
||||
timer_info.created_seconds,
|
||||
timer_info.seconds_left,
|
||||
timer_info.is_active,
|
||||
)
|
||||
|
||||
async def _stream_tts_audio(
|
||||
self,
|
||||
media_id: str,
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
sample_channels: int = 1,
|
||||
samples_per_chunk: int = 512,
|
||||
) -> None:
|
||||
"""Stream TTS audio chunks to device via API or UDP."""
|
||||
self.cli.send_voice_assistant_event(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {}
|
||||
)
|
||||
|
||||
try:
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
extension, data = await tts.async_get_media_source_audio(
|
||||
self.hass,
|
||||
media_id,
|
||||
)
|
||||
|
||||
if extension != "wav":
|
||||
_LOGGER.error("Only WAV audio can be streamed, got %s", extension)
|
||||
return
|
||||
|
||||
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
|
||||
if (
|
||||
(wav_file.getframerate() != sample_rate)
|
||||
or (wav_file.getsampwidth() != sample_width)
|
||||
or (wav_file.getnchannels() != sample_channels)
|
||||
):
|
||||
_LOGGER.error("Can only stream 16Khz 16-bit mono WAV")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Streaming %s audio samples", wav_file.getnframes())
|
||||
|
||||
while self._is_running:
|
||||
chunk = wav_file.readframes(samples_per_chunk)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if self._udp_server is not None:
|
||||
self._udp_server.send_audio_bytes(chunk)
|
||||
else:
|
||||
self.cli.send_voice_assistant_audio(chunk)
|
||||
|
||||
# Wait for 90% of the duration of the audio that was
|
||||
# sent for it to be played. This will overrun the
|
||||
# device's buffer for very long audio, so using a media
|
||||
# player is preferred.
|
||||
samples_in_chunk = len(chunk) // (sample_width * sample_channels)
|
||||
seconds_in_chunk = samples_in_chunk / sample_rate
|
||||
await asyncio.sleep(seconds_in_chunk * 0.9)
|
||||
except asyncio.CancelledError:
|
||||
return # Don't trigger state change
|
||||
finally:
|
||||
self.cli.send_voice_assistant_event(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {}
|
||||
)
|
||||
|
||||
# State change
|
||||
self.tts_response_finished()
|
||||
|
||||
async def _wrap_audio_stream(self) -> AsyncIterable[bytes]:
|
||||
"""Yield audio chunks from the queue until None."""
|
||||
while True:
|
||||
chunk = await self._audio_queue.get()
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
yield chunk
|
||||
|
||||
def _stop_pipeline(self) -> None:
|
||||
"""Request pipeline to be stopped."""
|
||||
self._audio_queue.put_nowait(None)
|
||||
_LOGGER.debug("Requested pipeline stop")
|
||||
|
||||
async def _start_udp_server(self) -> int:
|
||||
"""Start a UDP server on a random free port."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
sock.bind(("", 0)) # random free port
|
||||
|
||||
(
|
||||
_transport,
|
||||
protocol,
|
||||
) = await asyncio.get_running_loop().create_datagram_endpoint(
|
||||
partial(VoiceAssistantUDPServer, self._audio_queue), sock=sock
|
||||
)
|
||||
|
||||
assert isinstance(protocol, VoiceAssistantUDPServer)
|
||||
self._udp_server = protocol
|
||||
|
||||
# Return port
|
||||
return cast(int, sock.getsockname()[1])
|
||||
|
||||
def _stop_udp_server(self) -> None:
|
||||
"""Stop the UDP server if it's running."""
|
||||
if self._udp_server is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._udp_server.close()
|
||||
finally:
|
||||
self._udp_server = None
|
||||
|
||||
_LOGGER.debug("Stopped UDP server")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
"""Receive UDP packets and forward them to the audio queue."""
|
||||
|
||||
transport: asyncio.DatagramTransport | None = None
|
||||
remote_addr: tuple[str, int] | None = None
|
||||
|
||||
def __init__(
|
||||
self, audio_queue: asyncio.Queue[bytes | None], *args: Any, **kwargs: Any
|
||||
) -> None:
|
||||
"""Initialize protocol."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._audio_queue = audio_queue
|
||||
|
||||
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||
"""Store transport for later use."""
|
||||
self.transport = cast(asyncio.DatagramTransport, transport)
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||
"""Handle incoming UDP packet."""
|
||||
if self.remote_addr is None:
|
||||
self.remote_addr = addr
|
||||
|
||||
self._audio_queue.put_nowait(data)
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
"""Handle when a send or receive operation raises an OSError.
|
||||
|
||||
(Other than BlockingIOError or InterruptedError.)
|
||||
"""
|
||||
_LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc)
|
||||
|
||||
# Stop pipeline
|
||||
self._audio_queue.put_nowait(None)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the receiver."""
|
||||
if self.transport is not None:
|
||||
self.transport.close()
|
||||
|
||||
self.remote_addr = None
|
||||
|
||||
def send_audio_bytes(self, data: bytes) -> None:
|
||||
"""Send bytes to the device via UDP."""
|
||||
if self.transport is None:
|
||||
_LOGGER.error("No transport to send audio to")
|
||||
return
|
||||
|
||||
if self.remote_addr is None:
|
||||
_LOGGER.error("No address to send audio to")
|
||||
return
|
||||
|
||||
self.transport.sendto(data, self.remote_addr)
|
||||
@@ -20,19 +20,17 @@ from aioesphomeapi import (
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
UserServiceArgType,
|
||||
VoiceAssistantAudioSettings,
|
||||
VoiceAssistantFeature,
|
||||
)
|
||||
from awesomeversion import AwesomeVersion
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import tag, zeroconf
|
||||
from homeassistant.components.intent import async_register_timer_handler
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
CONF_MODE,
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
@@ -73,12 +71,6 @@ from .domain_data import DomainData
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .voice_assistant import (
|
||||
VoiceAssistantAPIPipeline,
|
||||
VoiceAssistantPipeline,
|
||||
VoiceAssistantUDPPipeline,
|
||||
handle_timer_event,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -149,7 +141,6 @@ class ESPHomeManager:
|
||||
"cli",
|
||||
"device_id",
|
||||
"domain_data",
|
||||
"voice_assistant_pipeline",
|
||||
"reconnect_logic",
|
||||
"zeroconf_instance",
|
||||
"entry_data",
|
||||
@@ -173,7 +164,6 @@ class ESPHomeManager:
|
||||
self.cli = cli
|
||||
self.device_id: str | None = None
|
||||
self.domain_data = domain_data
|
||||
self.voice_assistant_pipeline: VoiceAssistantPipeline | None = None
|
||||
self.reconnect_logic: ReconnectLogic | None = None
|
||||
self.zeroconf_instance = zeroconf_instance
|
||||
self.entry_data = entry.runtime_data
|
||||
@@ -338,77 +328,6 @@ class ESPHomeManager:
|
||||
entity_id, attribute, self.hass.states.get(entity_id)
|
||||
)
|
||||
|
||||
def _handle_pipeline_finished(self) -> None:
|
||||
self.entry_data.async_set_assist_pipeline_state(False)
|
||||
|
||||
if self.voice_assistant_pipeline is not None:
|
||||
if isinstance(self.voice_assistant_pipeline, VoiceAssistantUDPPipeline):
|
||||
self.voice_assistant_pipeline.close()
|
||||
self.voice_assistant_pipeline = None
|
||||
|
||||
async def _handle_pipeline_start(
|
||||
self,
|
||||
conversation_id: str,
|
||||
flags: int,
|
||||
audio_settings: VoiceAssistantAudioSettings,
|
||||
wake_word_phrase: str | None,
|
||||
) -> int | None:
|
||||
"""Start a voice assistant pipeline."""
|
||||
if self.voice_assistant_pipeline is not None:
|
||||
_LOGGER.warning("Previous Voice assistant pipeline was not stopped")
|
||||
self.voice_assistant_pipeline.stop()
|
||||
self.voice_assistant_pipeline = None
|
||||
|
||||
hass = self.hass
|
||||
assert self.entry_data.device_info is not None
|
||||
if (
|
||||
self.entry_data.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
& VoiceAssistantFeature.API_AUDIO
|
||||
):
|
||||
self.voice_assistant_pipeline = VoiceAssistantAPIPipeline(
|
||||
hass,
|
||||
self.entry_data,
|
||||
self.cli.send_voice_assistant_event,
|
||||
self._handle_pipeline_finished,
|
||||
self.cli,
|
||||
)
|
||||
port = 0
|
||||
else:
|
||||
self.voice_assistant_pipeline = VoiceAssistantUDPPipeline(
|
||||
hass,
|
||||
self.entry_data,
|
||||
self.cli.send_voice_assistant_event,
|
||||
self._handle_pipeline_finished,
|
||||
)
|
||||
port = await self.voice_assistant_pipeline.start_server()
|
||||
|
||||
assert self.device_id is not None, "Device ID must be set"
|
||||
hass.async_create_background_task(
|
||||
self.voice_assistant_pipeline.run_pipeline(
|
||||
device_id=self.device_id,
|
||||
conversation_id=conversation_id or None,
|
||||
flags=flags,
|
||||
audio_settings=audio_settings,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
),
|
||||
"esphome.voice_assistant_pipeline.run_pipeline",
|
||||
)
|
||||
|
||||
return port
|
||||
|
||||
async def _handle_pipeline_stop(self) -> None:
|
||||
"""Stop a voice assistant pipeline."""
|
||||
if self.voice_assistant_pipeline is not None:
|
||||
self.voice_assistant_pipeline.stop()
|
||||
|
||||
async def _handle_audio(self, data: bytes) -> None:
|
||||
if self.voice_assistant_pipeline is None:
|
||||
return
|
||||
assert isinstance(self.voice_assistant_pipeline, VoiceAssistantAPIPipeline)
|
||||
self.voice_assistant_pipeline.receive_audio_bytes(data)
|
||||
|
||||
async def on_connect(self) -> None:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
try:
|
||||
@@ -509,29 +428,14 @@ class ESPHomeManager:
|
||||
)
|
||||
)
|
||||
|
||||
flags = device_info.voice_assistant_feature_flags_compat(api_version)
|
||||
if flags:
|
||||
if flags & VoiceAssistantFeature.API_AUDIO:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_voice_assistant(
|
||||
handle_start=self._handle_pipeline_start,
|
||||
handle_stop=self._handle_pipeline_stop,
|
||||
handle_audio=self._handle_audio,
|
||||
)
|
||||
)
|
||||
else:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_voice_assistant(
|
||||
handle_start=self._handle_pipeline_start,
|
||||
handle_stop=self._handle_pipeline_stop,
|
||||
)
|
||||
)
|
||||
if flags & VoiceAssistantFeature.TIMERS:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
async_register_timer_handler(
|
||||
hass, self.device_id, partial(handle_timer_event, cli)
|
||||
)
|
||||
)
|
||||
if device_info.voice_assistant_feature_flags_compat(api_version) and (
|
||||
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
|
||||
):
|
||||
# Create assist satellite entity
|
||||
await self.hass.config_entries.async_forward_entry_setups(
|
||||
self.entry, [Platform.ASSIST_SATELLITE]
|
||||
)
|
||||
entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
|
||||
|
||||
cli.subscribe_states(entry_data.async_update_state)
|
||||
cli.subscribe_service_calls(self.async_on_service_call)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==25.3.1",
|
||||
"aioesphomeapi==25.3.2",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==1.0.0"
|
||||
],
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
"""ESPHome voice assistant support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
import io
|
||||
import logging
|
||||
import socket
|
||||
from typing import cast
|
||||
import wave
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
VoiceAssistantAudioSettings,
|
||||
VoiceAssistantCommandFlag,
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
|
||||
from homeassistant.components import stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
AudioSettings,
|
||||
PipelineEvent,
|
||||
PipelineEventType,
|
||||
PipelineNotFound,
|
||||
PipelineStage,
|
||||
WakeWordSettings,
|
||||
async_pipeline_from_audio_stream,
|
||||
select as pipeline_select,
|
||||
)
|
||||
from homeassistant.components.assist_pipeline.error import (
|
||||
WakeWordDetectionAborted,
|
||||
WakeWordDetectionError,
|
||||
)
|
||||
from homeassistant.components.assist_pipeline.vad import VadSensitivity
|
||||
from homeassistant.components.intent.timers import TimerEventType, TimerInfo
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entry_data import RuntimeEntryData
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UDP_PORT = 0 # Set to 0 to let the OS pick a free random port
|
||||
UDP_MAX_PACKET_SIZE = 1024
|
||||
|
||||
_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
|
||||
VoiceAssistantEventType, PipelineEventType
|
||||
] = EsphomeEnumMapper(
|
||||
{
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END,
|
||||
}
|
||||
)
|
||||
|
||||
_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = (
|
||||
EsphomeEnumMapper(
|
||||
{
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED,
|
||||
VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VoiceAssistantPipeline:
|
||||
"""Base abstract pipeline class."""
|
||||
|
||||
started = False
|
||||
stop_requested = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: RuntimeEntryData,
|
||||
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
|
||||
handle_finished: Callable[[], None],
|
||||
) -> None:
|
||||
"""Initialize the pipeline."""
|
||||
self.context = Context()
|
||||
self.hass = hass
|
||||
self.entry_data = entry_data
|
||||
assert entry_data.device_info is not None
|
||||
self.device_info = entry_data.device_info
|
||||
|
||||
self.queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
self.handle_event = handle_event
|
||||
self.handle_finished = handle_finished
|
||||
self._tts_done = asyncio.Event()
|
||||
self._tts_task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""True if the pipeline is started and hasn't been asked to stop."""
|
||||
return self.started and (not self.stop_requested)
|
||||
|
||||
async def _iterate_packets(self) -> AsyncIterable[bytes]:
|
||||
"""Iterate over incoming packets."""
|
||||
while data := await self.queue.get():
|
||||
if not self.is_running:
|
||||
break
|
||||
|
||||
yield data
|
||||
|
||||
def _event_callback(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
|
||||
try:
|
||||
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Received unknown pipeline event type: %s", event.type)
|
||||
return
|
||||
|
||||
data_to_send = None
|
||||
error = False
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START:
|
||||
self.entry_data.async_set_assist_pipeline_state(True)
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"conversation_id": event.data["intent_output"]["conversation_id"] or "",
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["tts_input"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
assert event.data is not None
|
||||
tts_output = event.data["tts_output"]
|
||||
if tts_output:
|
||||
path = tts_output["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
|
||||
if (
|
||||
self.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
& VoiceAssistantFeature.SPEAKER
|
||||
):
|
||||
media_id = tts_output["media_id"]
|
||||
self._tts_task = self.hass.async_create_background_task(
|
||||
self._send_tts(media_id), "esphome_voice_assistant_tts"
|
||||
)
|
||||
else:
|
||||
self._tts_done.set()
|
||||
else:
|
||||
# Empty TTS response
|
||||
data_to_send = {}
|
||||
self._tts_done.set()
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END:
|
||||
assert event.data is not None
|
||||
if not event.data["wake_word_output"]:
|
||||
event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR
|
||||
data_to_send = {
|
||||
"code": "no_wake_word",
|
||||
"message": "No wake word detected",
|
||||
}
|
||||
error = True
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"code": event.data["code"],
|
||||
"message": event.data["message"],
|
||||
}
|
||||
error = True
|
||||
|
||||
self.handle_event(event_type, data_to_send)
|
||||
if error:
|
||||
self._tts_done.set()
|
||||
self.handle_finished()
|
||||
|
||||
async def run_pipeline(
|
||||
self,
|
||||
device_id: str,
|
||||
conversation_id: str | None,
|
||||
flags: int = 0,
|
||||
audio_settings: VoiceAssistantAudioSettings | None = None,
|
||||
wake_word_phrase: str | None = None,
|
||||
) -> None:
|
||||
"""Run the Voice Assistant pipeline."""
|
||||
if audio_settings is None or audio_settings.volume_multiplier == 0:
|
||||
audio_settings = VoiceAssistantAudioSettings()
|
||||
|
||||
if (
|
||||
self.device_info.voice_assistant_feature_flags_compat(
|
||||
self.entry_data.api_version
|
||||
)
|
||||
& VoiceAssistantFeature.SPEAKER
|
||||
):
|
||||
tts_audio_output = "wav"
|
||||
else:
|
||||
tts_audio_output = "mp3"
|
||||
|
||||
_LOGGER.debug("Starting pipeline")
|
||||
if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD:
|
||||
start_stage = PipelineStage.WAKE_WORD
|
||||
else:
|
||||
start_stage = PipelineStage.STT
|
||||
try:
|
||||
await async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self.context,
|
||||
event_callback=self._event_callback,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=self._iterate_packets(),
|
||||
pipeline_id=pipeline_select.get_chosen_pipeline(
|
||||
self.hass, DOMAIN, self.device_info.mac_address
|
||||
),
|
||||
conversation_id=conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output=tts_audio_output,
|
||||
start_stage=start_stage,
|
||||
wake_word_settings=WakeWordSettings(timeout=5),
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
noise_suppression_level=audio_settings.noise_suppression_level,
|
||||
auto_gain_dbfs=audio_settings.auto_gain,
|
||||
volume_multiplier=audio_settings.volume_multiplier,
|
||||
is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD),
|
||||
silence_seconds=VadSensitivity.to_seconds(
|
||||
pipeline_select.get_vad_sensitivity(
|
||||
self.hass, DOMAIN, self.device_info.mac_address
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Block until TTS is done sending
|
||||
await self._tts_done.wait()
|
||||
|
||||
_LOGGER.debug("Pipeline finished")
|
||||
except PipelineNotFound as e:
|
||||
self.handle_event(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
|
||||
{
|
||||
"code": e.code,
|
||||
"message": e.message,
|
||||
},
|
||||
)
|
||||
_LOGGER.warning("Pipeline not found")
|
||||
except WakeWordDetectionAborted:
|
||||
pass # Wake word detection was aborted and `handle_finished` is enough.
|
||||
except WakeWordDetectionError as e:
|
||||
self.handle_event(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
|
||||
{
|
||||
"code": e.code,
|
||||
"message": e.message,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
self.handle_finished()
|
||||
|
||||
async def _send_tts(self, media_id: str) -> None:
|
||||
"""Send TTS audio to device via UDP."""
|
||||
# Always send stream start/end events
|
||||
self.handle_event(VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {})
|
||||
|
||||
try:
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
extension, data = await tts.async_get_media_source_audio(
|
||||
self.hass,
|
||||
media_id,
|
||||
)
|
||||
|
||||
if extension != "wav":
|
||||
raise ValueError(f"Only WAV audio can be streamed, got {extension}")
|
||||
|
||||
with io.BytesIO(data) as wav_io:
|
||||
with wave.open(wav_io, "rb") as wav_file:
|
||||
sample_rate = wav_file.getframerate()
|
||||
sample_width = wav_file.getsampwidth()
|
||||
sample_channels = wav_file.getnchannels()
|
||||
|
||||
if (
|
||||
(sample_rate != 16000)
|
||||
or (sample_width != 2)
|
||||
or (sample_channels != 1)
|
||||
):
|
||||
raise ValueError(
|
||||
"Expected rate/width/channels as 16000/2/1,"
|
||||
" got {sample_rate}/{sample_width}/{sample_channels}}"
|
||||
)
|
||||
|
||||
audio_bytes = wav_file.readframes(wav_file.getnframes())
|
||||
|
||||
audio_bytes_size = len(audio_bytes)
|
||||
|
||||
_LOGGER.debug("Sending %d bytes of audio", audio_bytes_size)
|
||||
|
||||
bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8
|
||||
sample_offset = 0
|
||||
samples_left = audio_bytes_size // bytes_per_sample
|
||||
|
||||
while (samples_left > 0) and self.is_running:
|
||||
bytes_offset = sample_offset * bytes_per_sample
|
||||
chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024]
|
||||
samples_in_chunk = len(chunk) // bytes_per_sample
|
||||
samples_left -= samples_in_chunk
|
||||
|
||||
self.send_audio_bytes(chunk)
|
||||
await asyncio.sleep(
|
||||
samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.9
|
||||
)
|
||||
|
||||
sample_offset += samples_in_chunk
|
||||
finally:
|
||||
self.handle_event(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {}
|
||||
)
|
||||
self._tts_task = None
|
||||
self._tts_done.set()
|
||||
|
||||
def send_audio_bytes(self, data: bytes) -> None:
|
||||
"""Send bytes to the device."""
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the pipeline."""
|
||||
self.queue.put_nowait(b"")
|
||||
|
||||
|
||||
class VoiceAssistantUDPPipeline(asyncio.DatagramProtocol, VoiceAssistantPipeline):
|
||||
"""Receive UDP packets and forward them to the voice assistant."""
|
||||
|
||||
transport: asyncio.DatagramTransport | None = None
|
||||
remote_addr: tuple[str, int] | None = None
|
||||
|
||||
async def start_server(self) -> int:
|
||||
"""Start accepting connections."""
|
||||
|
||||
def accept_connection() -> VoiceAssistantUDPPipeline:
|
||||
"""Accept connection."""
|
||||
if self.started:
|
||||
raise RuntimeError("Can only start once")
|
||||
if self.stop_requested:
|
||||
raise RuntimeError("No longer accepting connections")
|
||||
|
||||
self.started = True
|
||||
return self
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
|
||||
sock.bind(("", UDP_PORT))
|
||||
|
||||
await asyncio.get_running_loop().create_datagram_endpoint(
|
||||
accept_connection, sock=sock
|
||||
)
|
||||
|
||||
return cast(int, sock.getsockname()[1])
|
||||
|
||||
@callback
|
||||
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||
"""Store transport for later use."""
|
||||
self.transport = cast(asyncio.DatagramTransport, transport)
|
||||
|
||||
@callback
|
||||
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||
"""Handle incoming UDP packet."""
|
||||
if not self.is_running:
|
||||
return
|
||||
if self.remote_addr is None:
|
||||
self.remote_addr = addr
|
||||
self.queue.put_nowait(data)
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
"""Handle when a send or receive operation raises an OSError.
|
||||
|
||||
(Other than BlockingIOError or InterruptedError.)
|
||||
"""
|
||||
_LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc)
|
||||
self.handle_finished()
|
||||
|
||||
@callback
|
||||
def stop(self) -> None:
|
||||
"""Stop the receiver."""
|
||||
super().stop()
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the receiver."""
|
||||
self.started = False
|
||||
self.stop_requested = True
|
||||
|
||||
if self.transport is not None:
|
||||
self.transport.close()
|
||||
|
||||
def send_audio_bytes(self, data: bytes) -> None:
|
||||
"""Send bytes to the device via UDP."""
|
||||
if self.transport is None:
|
||||
_LOGGER.error("No transport to send audio to")
|
||||
return
|
||||
self.transport.sendto(data, self.remote_addr)
|
||||
|
||||
|
||||
class VoiceAssistantAPIPipeline(VoiceAssistantPipeline):
|
||||
"""Send audio to the voice assistant via the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: RuntimeEntryData,
|
||||
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
|
||||
handle_finished: Callable[[], None],
|
||||
api_client: APIClient,
|
||||
) -> None:
|
||||
"""Initialize the pipeline."""
|
||||
super().__init__(hass, entry_data, handle_event, handle_finished)
|
||||
self.api_client = api_client
|
||||
self.started = True
|
||||
|
||||
def send_audio_bytes(self, data: bytes) -> None:
|
||||
"""Send bytes to the device via the API."""
|
||||
self.api_client.send_voice_assistant_audio(data)
|
||||
|
||||
@callback
|
||||
def receive_audio_bytes(self, data: bytes) -> None:
|
||||
"""Receive audio bytes from the device."""
|
||||
if not self.is_running:
|
||||
return
|
||||
self.queue.put_nowait(data)
|
||||
|
||||
@callback
|
||||
def stop(self) -> None:
|
||||
"""Stop the pipeline."""
|
||||
super().stop()
|
||||
|
||||
self.started = False
|
||||
self.stop_requested = True
|
||||
|
||||
|
||||
def handle_timer_event(
|
||||
api_client: APIClient, event_type: TimerEventType, timer_info: TimerInfo
|
||||
) -> None:
|
||||
"""Handle timer events."""
|
||||
try:
|
||||
native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type)
|
||||
except KeyError:
|
||||
_LOGGER.debug("Received unknown timer event type: %s", event_type)
|
||||
return
|
||||
|
||||
api_client.send_voice_assistant_timer_event(
|
||||
native_event_type,
|
||||
timer_info.id,
|
||||
timer_info.name,
|
||||
timer_info.created_seconds,
|
||||
timer_info.seconds_left,
|
||||
timer_info.is_active,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user