forked from home-assistant/core
Compare commits
299 Commits
echo
...
2025.3.0b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345ba73777 | ||
|
|
e4200a79a2 | ||
|
|
381fa65ba0 | ||
|
|
16314711b8 | ||
|
|
553abe4a4a | ||
|
|
6a1bbdb3a7 | ||
|
|
59d92c75bd | ||
|
|
7732e6878e | ||
|
|
2cde317d59 | ||
|
|
0c08430507 | ||
|
|
fa6d7d5e3c | ||
|
|
585b950a46 | ||
|
|
3effc2e182 | ||
|
|
0e1602ff71 | ||
|
|
693584ce29 | ||
|
|
2e972422c2 | ||
|
|
3a21c36173 | ||
|
|
25ee2e58a5 | ||
|
|
561b3ae21b | ||
|
|
5be7f49146 | ||
|
|
2694828451 | ||
|
|
3eea932b24 | ||
|
|
468208502f | ||
|
|
92268f894a | ||
|
|
5e5fd6a2f2 | ||
|
|
cadee73da8 | ||
|
|
51099ae7d6 | ||
|
|
b777c29bab | ||
|
|
fc1190dafd | ||
|
|
775a81829b | ||
|
|
998757f09e | ||
|
|
b964bc58be | ||
|
|
bd80a78848 | ||
|
|
37c8764426 | ||
|
|
9262dec444 | ||
|
|
3c3c4d2641 | ||
|
|
c1898ece80 | ||
|
|
fdf69fcd7d | ||
|
|
e403bee95b | ||
|
|
9be8fd4eac | ||
|
|
e09b40c2bd | ||
|
|
2826198d5d | ||
|
|
5324f3e542 | ||
|
|
7e97ef588b | ||
|
|
bb120020a8 | ||
|
|
bb9aba2a7d | ||
|
|
b676c2f61b | ||
|
|
0c092f80c7 | ||
|
|
2bf592d8aa | ||
|
|
e591157e37 | ||
|
|
ee01aa73b8 | ||
|
|
0f827fbf22 | ||
|
|
4dca4a64b5 | ||
|
|
b82886a3e1 | ||
|
|
fe396cdf4b | ||
|
|
5895245a31 | ||
|
|
861ba0ee5e | ||
|
|
d15f9edc57 | ||
|
|
cab6ec0363 | ||
|
|
eb26a2124b | ||
|
|
4530fe4bf7 | ||
|
|
b1865de58f | ||
|
|
3ff04d6d04 | ||
|
|
bd306abace | ||
|
|
412ceca6f7 | ||
|
|
8644fb1887 | ||
|
|
622be70fee | ||
|
|
7bc0c1b912 | ||
|
|
3230e741e9 | ||
|
|
81db3dea41 | ||
|
|
fe348e17a3 | ||
|
|
03f6508bd8 | ||
|
|
fd47d6578e | ||
|
|
df6a5d7459 | ||
|
|
b8a0cdea12 | ||
|
|
570e11ba5b | ||
|
|
19704cff04 | ||
|
|
51c09c2aa4 | ||
|
|
ef46552146 | ||
|
|
75533463f7 | ||
|
|
2cd496fdaf | ||
|
|
cd4c79450b | ||
|
|
a1d1f6ec97 | ||
|
|
a910fb879c | ||
|
|
4e904bf5a3 | ||
|
|
38cc26485a | ||
|
|
2bba185e4c | ||
|
|
743cc42829 | ||
|
|
f3021b40ab | ||
|
|
9ec9110e1e | ||
|
|
433c2cb43e | ||
|
|
fcffe5151d | ||
|
|
ca1677cc46 | ||
|
|
27f7085b61 | ||
|
|
f607b95c00 | ||
|
|
72502c1a15 | ||
|
|
47e78e9008 | ||
|
|
1fb51ef189 | ||
|
|
f96e31fad8 | ||
|
|
e99bf21a36 | ||
|
|
3059d06960 | ||
|
|
2b55f3af36 | ||
|
|
776501f5e6 | ||
|
|
1f93d2cefb | ||
|
|
1633700a58 | ||
|
|
923ec71bf6 | ||
|
|
7566046995 | ||
|
|
b9dbf07a5e | ||
|
|
b8b153b87f | ||
|
|
d4dd8fd902 | ||
|
|
a3bc55f49b | ||
|
|
7ba94a680d | ||
|
|
664e09790c | ||
|
|
d45fce86a9 | ||
|
|
507c0739df | ||
|
|
d7301c62e2 | ||
|
|
befed910da | ||
|
|
2509353221 | ||
|
|
694a77fe3c | ||
|
|
bc7f5f3981 | ||
|
|
cea5cda881 | ||
|
|
9e063fd77c | ||
|
|
01fb6841da | ||
|
|
48d3dd88a1 | ||
|
|
051cc41d4f | ||
|
|
661b55d6eb | ||
|
|
d197acc069 | ||
|
|
bf190a8a73 | ||
|
|
c386abd49d | ||
|
|
6342d8334b | ||
|
|
24bb13e0d1 | ||
|
|
212c42ca77 | ||
|
|
54843bb422 | ||
|
|
c115a7f455 | ||
|
|
597c0ab985 | ||
|
|
b86bb75e5e | ||
|
|
b662d32e44 | ||
|
|
72f690d681 | ||
|
|
33c9f3cc7d | ||
|
|
a1076300c8 | ||
|
|
dc92e912c2 | ||
|
|
2451e5578a | ||
|
|
1c83dab0a1 | ||
|
|
b42973040c | ||
|
|
6507955a14 | ||
|
|
79dbc70470 | ||
|
|
2bab7436d3 | ||
|
|
60479369b6 | ||
|
|
ec3f5561dc | ||
|
|
2e5f56b70d | ||
|
|
461039f06a | ||
|
|
351e594fe4 | ||
|
|
377da5f954 | ||
|
|
51a881f3b5 | ||
|
|
5025e31129 | ||
|
|
f98720e525 | ||
|
|
37240e811b | ||
|
|
0b7a023d2e | ||
|
|
beec67a247 | ||
|
|
571349e3a2 | ||
|
|
d9eb248e91 | ||
|
|
fc8affd243 | ||
|
|
4d6fd1b10f | ||
|
|
257242e6e3 | ||
|
|
7f494c235c | ||
|
|
8c42db7501 | ||
|
|
183bbcd1e1 | ||
|
|
8c4b8028cf | ||
|
|
ea1045d826 | ||
|
|
db5bf41790 | ||
|
|
580c6f2684 | ||
|
|
d62c18c225 | ||
|
|
8f9f9bc8e7 | ||
|
|
6ad6e82a23 | ||
|
|
3d507c7b44 | ||
|
|
4f5c7353f8 | ||
|
|
0b961d98f5 | ||
|
|
1cd82ab8ee | ||
|
|
cc792403ab | ||
|
|
3d2ab3b59e | ||
|
|
ba1650bd05 | ||
|
|
df5f6fc1e6 | ||
|
|
0dbdb42947 | ||
|
|
325022ec77 | ||
|
|
3ea1d2823e | ||
|
|
83d9c000d3 | ||
|
|
266612e4d9 | ||
|
|
dc7cba60bd | ||
|
|
d752a3a24c | ||
|
|
8c3ee80203 | ||
|
|
94555f533b | ||
|
|
6da33a8883 | ||
|
|
d42e31b5e7 | ||
|
|
441917706b | ||
|
|
12e530dc75 | ||
|
|
59651c6f10 | ||
|
|
ac21d2855c | ||
|
|
6070feea73 | ||
|
|
167881e434 | ||
|
|
35bcf82627 | ||
|
|
66bb501621 | ||
|
|
179ba8309d | ||
|
|
2b7543aca2 | ||
|
|
1e49e04491 | ||
|
|
e60b6482ab | ||
|
|
7b82781f4c | ||
|
|
b40daf0152 | ||
|
|
417ac56bd6 | ||
|
|
c9a0814142 | ||
|
|
2d8a619b54 | ||
|
|
759cc3303a | ||
|
|
5328429b08 | ||
|
|
21b98a76cc | ||
|
|
95f632a13a | ||
|
|
33d4d1f8e5 | ||
|
|
72878c18d0 | ||
|
|
ccd220ad0f | ||
|
|
f191f6ae22 | ||
|
|
28a18e538d | ||
|
|
c2f6255d16 | ||
|
|
e5fd08ae76 | ||
|
|
4b5633d9d8 | ||
|
|
a9c6a06704 | ||
|
|
0faa8efd5a | ||
|
|
5a257b090e | ||
|
|
41fb6a537f | ||
|
|
b166c32eb8 | ||
|
|
288acfb511 | ||
|
|
2cb9682303 | ||
|
|
7e52170789 | ||
|
|
979b3d4269 | ||
|
|
9772014bce | ||
|
|
f8763c49ef | ||
|
|
b4ef00659c | ||
|
|
df49c53bb6 | ||
|
|
8dfe483b38 | ||
|
|
b45d7cbbc3 | ||
|
|
239ba9b1cc | ||
|
|
2d5a75d4f2 | ||
|
|
e1ad3f05e6 | ||
|
|
b9280edbfa | ||
|
|
010993fc5f | ||
|
|
713931661e | ||
|
|
af06521f66 | ||
|
|
c32f57f85a | ||
|
|
171061a778 | ||
|
|
476ea35bdb | ||
|
|
00e6866664 | ||
|
|
201bf95ab8 | ||
|
|
ff22bbd0e4 | ||
|
|
fd8d4e937c | ||
|
|
7903348d79 | ||
|
|
090dbba06e | ||
|
|
af77e69eb0 | ||
|
|
23e7638687 | ||
|
|
36b722960a | ||
|
|
3dd241a398 | ||
|
|
b5a9c3d1f6 | ||
|
|
eca714a45a | ||
|
|
8049699efb | ||
|
|
7c6afd50dc | ||
|
|
42d8889778 | ||
|
|
a4c0304e1f | ||
|
|
c63e688ba8 | ||
|
|
16298b4195 | ||
|
|
da23eb22db | ||
|
|
4bd1d0199b | ||
|
|
efe7050030 | ||
|
|
79ff85f517 | ||
|
|
73ad4caf94 | ||
|
|
e3d649d349 | ||
|
|
657e3488ba | ||
|
|
7508c14a53 | ||
|
|
ac84970da8 | ||
|
|
30073f3493 | ||
|
|
3abd7b8ba3 | ||
|
|
62bc6e4bf6 | ||
|
|
5faa189fef | ||
|
|
e09ae1c83d | ||
|
|
7b20299de7 | ||
|
|
81e501aba1 | ||
|
|
568ac22ce8 | ||
|
|
c71ab054f1 | ||
|
|
bea201f9f6 | ||
|
|
dda90bc04c | ||
|
|
a033e4c88d | ||
|
|
42b6f83e7c | ||
|
|
cb937bc115 | ||
|
|
bec569caf9 | ||
|
|
3390fb32a8 | ||
|
|
3ebb58f780 | ||
|
|
30b131d3b9 | ||
|
|
cd40232beb | ||
|
|
f27fe365c5 | ||
|
|
1c769418fb | ||
|
|
db7c2dab52 | ||
|
|
627377872b | ||
|
|
8504162539 | ||
|
|
67c6a1d436 |
26
.github/workflows/builder.yml
vendored
26
.github/workflows/builder.yml
vendored
@@ -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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2024.08.2
|
||||
uses: home-assistant/builder@2025.02.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2024.08.2
|
||||
uses: home-assistant/builder@2025.02.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -448,6 +448,9 @@ jobs:
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
@@ -459,7 +462,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -473,16 +476,13 @@ jobs:
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install twine build
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package
|
||||
shell: bash
|
||||
run: |
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
|
||||
|
||||
twine upload dist/* --skip-existing
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.12.4
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
|
||||
28
.github/workflows/ci.yaml
vendored
28
.github/workflows/ci.yaml
vendored
@@ -537,7 +537,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -661,7 +661,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
|
||||
- name: Upload licenses
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
|
||||
path: licenses-${{ matrix.python-version }}.json
|
||||
@@ -877,7 +877,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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest_buckets
|
||||
path: pytest_buckets.txt
|
||||
@@ -942,7 +942,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- name: Compile English translations
|
||||
@@ -980,14 +980,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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1108,7 +1108,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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1116,7 +1116,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1239,7 +1239,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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1247,7 +1247,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1271,7 +1271,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1382,14 +1382,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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
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.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1410,7 +1410,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.9
|
||||
uses: github/codeql-action/init@v3.28.10
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.9
|
||||
uses: github/codeql-action/analyze@v3.28.10
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
22
.github/workflows/wheels.yml
vendored
22
.github/workflows/wheels.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -99,14 +99,14 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
@@ -138,17 +138,17 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -187,22 +187,22 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@v4.1.9
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ homeassistant.components.auth.*
|
||||
homeassistant.components.automation.*
|
||||
homeassistant.components.awair.*
|
||||
homeassistant.components.axis.*
|
||||
homeassistant.components.azure_storage.*
|
||||
homeassistant.components.backup.*
|
||||
homeassistant.components.baf.*
|
||||
homeassistant.components.bang_olufsen.*
|
||||
@@ -407,6 +408,7 @@ homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.renault.*
|
||||
homeassistant.components.reolink.*
|
||||
|
||||
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -38,7 +38,6 @@
|
||||
"module": "pytest",
|
||||
"justMyCode": false,
|
||||
"args": [
|
||||
"--timeout=10",
|
||||
"--picked"
|
||||
],
|
||||
},
|
||||
|
||||
20
CODEOWNERS
generated
20
CODEOWNERS
generated
@@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/azure_event_hub/ @eavanvalkenburg
|
||||
/tests/components/azure_event_hub/ @eavanvalkenburg
|
||||
/homeassistant/components/azure_service_bus/ @hfurubotten
|
||||
/homeassistant/components/azure_storage/ @zweckj
|
||||
/tests/components/azure_storage/ @zweckj
|
||||
/homeassistant/components/backup/ @home-assistant/core
|
||||
/tests/components/backup/ @home-assistant/core
|
||||
/homeassistant/components/baf/ @bdraco @jfroy
|
||||
@@ -967,8 +969,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/motionblinds_ble/ @LennP @jerrybboy
|
||||
/homeassistant/components/motioneye/ @dermotduffy
|
||||
/tests/components/motioneye/ @dermotduffy
|
||||
/homeassistant/components/motionmount/ @RJPoelstra
|
||||
/tests/components/motionmount/ @RJPoelstra
|
||||
/homeassistant/components/motionmount/ @laiho-vogels
|
||||
/tests/components/motionmount/ @laiho-vogels
|
||||
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
@@ -1051,8 +1053,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/numato/ @clssn
|
||||
/homeassistant/components/number/ @home-assistant/core @Shulyaka
|
||||
/tests/components/number/ @home-assistant/core @Shulyaka
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nyt_games/ @joostlek
|
||||
@@ -1144,8 +1146,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/philips_js/ @elupus
|
||||
/homeassistant/components/pi_hole/ @shenxn
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl
|
||||
/tests/components/picnic/ @corneyl
|
||||
/homeassistant/components/picnic/ @corneyl @codesalatdev
|
||||
/tests/components/picnic/ @corneyl @codesalatdev
|
||||
/homeassistant/components/ping/ @jpbede
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
@@ -1399,6 +1401,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
/tests/components/smartthings/ @joostlek
|
||||
/homeassistant/components/smarttub/ @mdz
|
||||
/tests/components/smarttub/ @mdz
|
||||
/homeassistant/components/smarty/ @z0mbieprocess
|
||||
@@ -1413,6 +1417,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/snapcast/ @luar123
|
||||
/homeassistant/components/snmp/ @nmaggioni
|
||||
/tests/components/snmp/ @nmaggioni
|
||||
/homeassistant/components/snoo/ @Lash-L
|
||||
/tests/components/snoo/ @Lash-L
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck @bdraco
|
||||
@@ -1693,6 +1699,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
/tests/components/webhook/ @home-assistant/core
|
||||
/homeassistant/components/webmin/ @autinerd
|
||||
|
||||
@@ -74,6 +74,7 @@ from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
backup,
|
||||
category_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
@@ -163,16 +164,6 @@ FRONTEND_INTEGRATIONS = {
|
||||
# integrations can be removed and database migration status is
|
||||
# visible in frontend
|
||||
"frontend",
|
||||
# Hassio is an after dependency of backup, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here. Hassio needs to be setup before backup, otherwise
|
||||
# the backup integration will think we are a container/core install
|
||||
# when using HAOS or Supervised install.
|
||||
"hassio",
|
||||
# Backup is an after dependency of frontend, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here.
|
||||
"backup",
|
||||
}
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
@@ -206,6 +197,8 @@ STAGE_1_INTEGRATIONS = {
|
||||
"mqtt_eventstream",
|
||||
# To provide account link implementations
|
||||
"cloud",
|
||||
# Ensure supervisor is available
|
||||
"hassio",
|
||||
}
|
||||
|
||||
DEFAULT_INTEGRATIONS = {
|
||||
@@ -328,10 +321,10 @@ async def async_setup_hass(
|
||||
|
||||
block_async_io.enable()
|
||||
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
if not (recovery_mode := runtime_config.recovery_mode):
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
try:
|
||||
@@ -349,39 +342,43 @@ async def async_setup_hass(
|
||||
await async_from_config_dict(config_dict, hass) is not None
|
||||
)
|
||||
|
||||
if config_dict is None:
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
if config_dict is None:
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning(
|
||||
"Unable to set up core integrations. Activating recovery mode"
|
||||
)
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
)
|
||||
elif any(
|
||||
domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
)
|
||||
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
if old_logging:
|
||||
hass.data[DATA_LOGGING] = old_logging
|
||||
hass.config.debug = old_config.debug
|
||||
hass.config.skip_pip = old_config.skip_pip
|
||||
hass.config.skip_pip_packages = old_config.skip_pip_packages
|
||||
hass.config.internal_url = old_config.internal_url
|
||||
hass.config.external_url = old_config.external_url
|
||||
# Setup loader cache after the config dir has been set
|
||||
loader.async_setup(hass)
|
||||
if old_logging:
|
||||
hass.data[DATA_LOGGING] = old_logging
|
||||
hass.config.debug = old_config.debug
|
||||
hass.config.skip_pip = old_config.skip_pip
|
||||
hass.config.skip_pip_packages = old_config.skip_pip_packages
|
||||
hass.config.internal_url = old_config.internal_url
|
||||
hass.config.external_url = old_config.external_url
|
||||
# Setup loader cache after the config dir has been set
|
||||
loader.async_setup(hass)
|
||||
|
||||
if recovery_mode:
|
||||
_LOGGER.info("Starting in recovery mode")
|
||||
@@ -901,6 +898,10 @@ async def _async_set_up_integrations(
|
||||
if "recorder" in domains_to_setup:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
# Initialize backup
|
||||
if "backup" in domains_to_setup:
|
||||
backup.async_initialize_backup(hass)
|
||||
|
||||
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group & domains_to_setup, timeout)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"azure_devops",
|
||||
"azure_event_hub",
|
||||
"azure_service_bus",
|
||||
"azure_storage",
|
||||
"microsoft_face_detect",
|
||||
"microsoft_face_identify",
|
||||
"microsoft_face",
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.0.0"],
|
||||
"requirements": ["accuweather==4.1.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["adext", "alarmdecoder"],
|
||||
"requirements": ["adext==0.4.3"]
|
||||
"requirements": ["adext==0.4.4"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.1.2"],
|
||||
"requirements": ["androidtvremote2==0.2.0"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
import anthropic
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -20,7 +22,9 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Set up Anthropic from a config entry."""
|
||||
client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||
)
|
||||
try:
|
||||
await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
@@ -59,7 +60,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY])
|
||||
)
|
||||
await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
max_tokens=1,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.44.0"]
|
||||
"requirements": ["anthropic==0.47.2"]
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener):
|
||||
pass
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to connect")
|
||||
await self.disconnect()
|
||||
|
||||
async def _connect_loop(self) -> None:
|
||||
"""Connect loop background task function."""
|
||||
|
||||
@@ -1103,12 +1103,16 @@ class PipelineRun:
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents first, if preferred.
|
||||
elif self.pipeline.prefer_local_intents and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
|
||||
82
homeassistant/components/azure_storage/__init__.py
Normal file
82
homeassistant/components/azure_storage/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""The Azure Storage integration."""
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from azure.core.exceptions import (
|
||||
ClientAuthenticationError,
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type AzureStorageConfigEntry = ConfigEntry[ContainerClient]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Azure Storage integration."""
|
||||
# set increase aiohttp timeout for long running operations (up/download)
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
try:
|
||||
if not await container_client.exists():
|
||||
await container_client.create_container()
|
||||
except ResourceNotFoundError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_found",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except ClientAuthenticationError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except HttpResponseError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = container_client
|
||||
|
||||
def _async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Unload an Azure Storage config entry."""
|
||||
return True
|
||||
182
homeassistant/components/azure_storage/backup.py
Normal file
182
homeassistant/components/azure_storage/backup.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Support for Azure Storage backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.storage.blob import BlobProperties
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import AzureStorageConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
METADATA_VERSION = "1"
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
return [AzureStorageBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed."""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
hass.data.pop(DATA_BACKUP_AGENT_LISTENERS)
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except HttpResponseError as err:
|
||||
_LOGGER.debug(
|
||||
"Error during backup in %s: Status %s, message %s",
|
||||
func.__name__,
|
||||
err.status_code,
|
||||
err.message,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Azure storage backup agent."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None:
|
||||
"""Initialize the Azure storage backup agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
download_stream = await self._client.download_blob(blob.name)
|
||||
return download_stream.chunks()
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
metadata = {
|
||||
"metadata_version": METADATA_VERSION,
|
||||
"backup_id": backup.backup_id,
|
||||
"backup_metadata": json.dumps(backup.as_dict()),
|
||||
}
|
||||
|
||||
await self._client.upload_blob(
|
||||
name=suggested_filename(backup),
|
||||
metadata=metadata,
|
||||
data=await open_stream(),
|
||||
length=backup.size,
|
||||
)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return
|
||||
await self._client.delete_blob(blob.name)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups: list[AgentBackup] = []
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
metadata = blob.metadata
|
||||
|
||||
if metadata.get("metadata_version") == METADATA_VERSION:
|
||||
backups.append(
|
||||
AgentBackup.from_dict(json.loads(metadata["backup_metadata"]))
|
||||
)
|
||||
|
||||
return backups
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return None
|
||||
|
||||
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
|
||||
|
||||
async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None:
|
||||
"""Find a blob by backup id."""
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
if (
|
||||
backup_id == blob.metadata.get("backup_id", "")
|
||||
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
||||
):
|
||||
return blob
|
||||
return None
|
||||
72
homeassistant/components/azure_storage/config_flow.py
Normal file
72
homeassistant/components/azure_storage/config_flow.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Config flow for Azure Storage integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User step for Azure Storage."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
try:
|
||||
await container_client.exists()
|
||||
except ResourceNotFoundError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ClientAuthenticationError:
|
||||
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NAME): str,
|
||||
vol.Required(
|
||||
CONF_CONTAINER_NAME, default="home-assistant-backups"
|
||||
): str,
|
||||
vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
16
homeassistant/components/azure_storage/const.py
Normal file
16
homeassistant/components/azure_storage/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Azure Storage integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "azure_storage"
|
||||
|
||||
CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key"
|
||||
CONF_ACCOUNT_NAME: Final = "account_name"
|
||||
CONF_CONTAINER_NAME: Final = "container_name"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
12
homeassistant/components/azure_storage/manifest.json
Normal file
12
homeassistant/components/azure_storage/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "azure_storage",
|
||||
"name": "Azure Storage",
|
||||
"codeowners": ["@zweckj"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_storage",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["azure-storage-blob"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["azure-storage-blob==12.24.0"]
|
||||
}
|
||||
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
@@ -0,0 +1,133 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have platforms.
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll or push.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only serves backup.
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service.
|
||||
docs-supported-functions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
48
homeassistant/components/azure_storage/strings.json
Normal file
48
homeassistant/components/azure_storage/strings.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"storage_account_key": "Storage account key",
|
||||
"account_name": "Account name",
|
||||
"container_name": "Container name"
|
||||
},
|
||||
"data_description": {
|
||||
"storage_account_key": "Storage account access key used for authorization",
|
||||
"account_name": "Name of the storage account",
|
||||
"container_name": "Name of the storage container to be used (will be created if it does not exist)"
|
||||
},
|
||||
"description": "Set up an Azure (Blob) storage account to be used for backups.",
|
||||
"title": "Add Azure storage account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"container_not_found": {
|
||||
"title": "Storage container not found",
|
||||
"description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_not_found": {
|
||||
"message": "Storage account {account_name} not found"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can not connect to storage account {account_name}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed for storage account {account_name}"
|
||||
},
|
||||
"container_not_found": {
|
||||
"message": "Storage container {container_name} not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -32,6 +32,7 @@ from .manager import (
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
ManagerStateEvent,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
@@ -63,12 +64,12 @@ __all__ = [
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"ManagerStateEvent",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
@@ -91,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
backup_manager = BackupManager(hass, reader_writer)
|
||||
hass.data[DATA_MANAGER] = backup_manager
|
||||
await backup_manager.async_setup()
|
||||
try:
|
||||
await backup_manager.async_setup()
|
||||
except Exception as err:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||
raise
|
||||
else:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
@@ -122,15 +129,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_register_http_views(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
||||
"""Get the backup manager instance.
|
||||
|
||||
Raises HomeAssistantError if the backup integration is not available.
|
||||
"""
|
||||
if DATA_MANAGER not in hass.data:
|
||||
raise HomeAssistantError("Backup integration is not available")
|
||||
|
||||
return hass.data[DATA_MANAGER]
|
||||
|
||||
38
homeassistant/components/backup/basic_websocket.py
Normal file
38
homeassistant/components/backup/basic_websocket.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Websocket commands for the Backup integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import async_subscribe_events
|
||||
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import ManagerStateEvent
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||
"""Register websocket commands."""
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
if DATA_MANAGER in hass.data:
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict
|
||||
from cronsim import CronSim
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import BackupManagerError, Folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import BackupManager, ManagerBackup
|
||||
|
||||
AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"
|
||||
|
||||
CRON_PATTERN_DAILY = "{m} {h} * * *"
|
||||
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
|
||||
|
||||
@@ -39,6 +42,7 @@ class StoredBackupConfig(TypedDict):
|
||||
"""Represent the stored backup config."""
|
||||
|
||||
agents: dict[str, StoredAgentConfig]
|
||||
automatic_backups_configured: bool
|
||||
create_backup: StoredCreateBackupConfig
|
||||
last_attempted_automatic_backup: str | None
|
||||
last_completed_automatic_backup: str | None
|
||||
@@ -51,6 +55,7 @@ class BackupConfigData:
|
||||
"""Represent loaded backup config data."""
|
||||
|
||||
agents: dict[str, AgentConfig]
|
||||
automatic_backups_configured: bool # only used by frontend
|
||||
create_backup: CreateBackupConfig
|
||||
last_attempted_automatic_backup: datetime | None = None
|
||||
last_completed_automatic_backup: datetime | None = None
|
||||
@@ -88,6 +93,7 @@ class BackupConfigData:
|
||||
agent_id: AgentConfig(protected=agent_data["protected"])
|
||||
for agent_id, agent_data in data["agents"].items()
|
||||
},
|
||||
automatic_backups_configured=data["automatic_backups_configured"],
|
||||
create_backup=CreateBackupConfig(
|
||||
agent_ids=data["create_backup"]["agent_ids"],
|
||||
include_addons=data["create_backup"]["include_addons"],
|
||||
@@ -127,6 +133,7 @@ class BackupConfigData:
|
||||
agents={
|
||||
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
|
||||
},
|
||||
automatic_backups_configured=self.automatic_backups_configured,
|
||||
create_backup=self.create_backup.to_dict(),
|
||||
last_attempted_automatic_backup=last_attempted,
|
||||
last_completed_automatic_backup=last_completed,
|
||||
@@ -142,10 +149,12 @@ class BackupConfig:
|
||||
"""Initialize backup config."""
|
||||
self.data = BackupConfigData(
|
||||
agents={},
|
||||
automatic_backups_configured=False,
|
||||
create_backup=CreateBackupConfig(),
|
||||
retention=RetentionConfig(),
|
||||
schedule=BackupSchedule(),
|
||||
)
|
||||
self._hass = hass
|
||||
self._manager = manager
|
||||
|
||||
def load(self, stored_config: StoredBackupConfig) -> None:
|
||||
@@ -159,6 +168,7 @@ class BackupConfig:
|
||||
self,
|
||||
*,
|
||||
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
|
||||
automatic_backups_configured: bool | UndefinedType = UNDEFINED,
|
||||
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
|
||||
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
|
||||
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
|
||||
@@ -172,8 +182,12 @@ class BackupConfig:
|
||||
self.data.agents[agent_id] = replace(
|
||||
self.data.agents[agent_id], **agent_config
|
||||
)
|
||||
if automatic_backups_configured is not UNDEFINED:
|
||||
self.data.automatic_backups_configured = automatic_backups_configured
|
||||
if create_backup is not UNDEFINED:
|
||||
self.data.create_backup = replace(self.data.create_backup, **create_backup)
|
||||
if "agent_ids" in create_backup:
|
||||
check_unavailable_agents(self._hass, self._manager)
|
||||
if retention is not UNDEFINED:
|
||||
new_retention = RetentionConfig(**retention)
|
||||
if new_retention != self.data.retention:
|
||||
@@ -554,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
|
||||
await manager.async_delete_filtered_backups(
|
||||
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
|
||||
"""Check for unavailable agents."""
|
||||
if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
|
||||
manager.backup_agents
|
||||
):
|
||||
LOGGER.debug(
|
||||
"Agents %s are configured for automatic backup but are unavailable",
|
||||
missing_agent_ids,
|
||||
)
|
||||
|
||||
# Remove issues for unavailable agents that are not unavailable anymore.
|
||||
issue_registry = ir.async_get(hass)
|
||||
existing_missing_agent_issue_ids = {
|
||||
issue_id
|
||||
for domain, issue_id in issue_registry.issues
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
|
||||
}
|
||||
current_missing_agent_issue_ids = {
|
||||
f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
|
||||
for agent_id in missing_agent_ids
|
||||
}
|
||||
for issue_id in existing_missing_agent_issue_ids - set(
|
||||
current_missing_agent_issue_ids
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
for issue_id, agent_id in current_missing_agent_issue_ids.items():
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_agents_unavailable",
|
||||
translation_placeholders={
|
||||
"agent_id": agent_id,
|
||||
"backup_settings": "/config/backup/settings",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,7 +32,9 @@ from homeassistant.helpers import (
|
||||
instance_id,
|
||||
integration_platform,
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
|
||||
@@ -46,6 +48,7 @@ from .agent import (
|
||||
from .config import (
|
||||
BackupConfig,
|
||||
CreateBackupParametersDict,
|
||||
check_unavailable_agents,
|
||||
delete_backups_exceeding_configured_count,
|
||||
)
|
||||
from .const import (
|
||||
@@ -332,7 +335,9 @@ class BackupManager:
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = IdleEvent()
|
||||
self.last_non_idle_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_event_subscriptions
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the backup manager."""
|
||||
@@ -414,6 +419,13 @@ class BackupManager:
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def check_unavailable_agents_after_start(hass: HomeAssistant) -> None:
|
||||
"""Check unavailable agents after start."""
|
||||
check_unavailable_agents(hass, self)
|
||||
|
||||
start.async_at_started(self.hass, check_unavailable_agents_after_start)
|
||||
|
||||
async def _add_platform(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -1279,19 +1291,6 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
on_event: Callable[[ManagerStateEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
def _update_issue_backup_failed(self) -> None:
|
||||
"""Update issue registry when a backup fails."""
|
||||
ir.async_create_issue(
|
||||
@@ -1621,7 +1620,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
"""Generate backup contents and return the size."""
|
||||
if not tar_file_path:
|
||||
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
|
||||
make_backup_dir(tar_file_path.parent)
|
||||
try:
|
||||
make_backup_dir(tar_file_path.parent)
|
||||
except OSError as err:
|
||||
raise BackupReaderWriterError(
|
||||
f"Failed to create dir {tar_file_path.parent}: "
|
||||
f"{err} ({err.__class__.__name__})"
|
||||
) from err
|
||||
|
||||
excludes = EXCLUDE_FROM_BACKUP
|
||||
if not database_included:
|
||||
@@ -1659,7 +1664,14 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
file_filter=is_excluded_by_filter,
|
||||
arcname="data",
|
||||
)
|
||||
return (tar_file_path, tar_file_path.stat().st_size)
|
||||
try:
|
||||
stat_result = tar_file_path.stat()
|
||||
except OSError as err:
|
||||
raise BackupReaderWriterError(
|
||||
f"Error getting size of {tar_file_path}: "
|
||||
f"{err} ({err.__class__.__name__})"
|
||||
) from err
|
||||
return (tar_file_path, stat_result.st_size)
|
||||
|
||||
async def async_receive_backup(
|
||||
self,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["cronsim==2.6", "securetar==2025.1.4"]
|
||||
"requirements": ["cronsim==2.6", "securetar==2025.2.1"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 4
|
||||
STORAGE_VERSION_MINOR = 5
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
data["config"]["retention"]["copies"] = None
|
||||
if data["config"]["retention"]["days"] == 0:
|
||||
data["config"]["retention"]["days"] = None
|
||||
if old_minor_version < 5:
|
||||
# Version 1.5 adds automatic_backups_configured
|
||||
data["config"]["automatic_backups_configured"] = (
|
||||
data["config"]["create_backup"]["password"] is not None
|
||||
)
|
||||
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Reject if major version is higher than 2.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"issues": {
|
||||
"automatic_backup_agents_unavailable": {
|
||||
"title": "The backup location {agent_id} is unavailable",
|
||||
"description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable."
|
||||
},
|
||||
"automatic_backup_failed_create": {
|
||||
"title": "Automatic backup could not be created",
|
||||
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
|
||||
@@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .config import Day, ScheduleRecurrence
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import (
|
||||
DecryptOnDowloadNotSupported,
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||
websocket_api.async_register_command(hass, handle_delete)
|
||||
websocket_api.async_register_command(hass, handle_restore)
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_config_info)
|
||||
websocket_api.async_register_command(hass, handle_config_update)
|
||||
@@ -352,6 +347,7 @@ async def handle_config_info(
|
||||
{
|
||||
vol.Required("type"): "backup/config/update",
|
||||
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
|
||||
vol.Optional("automatic_backups_configured"): bool,
|
||||
vol.Optional("create_backup"): vol.Schema(
|
||||
{
|
||||
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
|
||||
@@ -400,22 +396,3 @@ def handle_config_update(
|
||||
changes.pop("type")
|
||||
manager.config.update(**changes)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"name": "Activity",
|
||||
"state": {
|
||||
"available": "Available",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"unavailable": "Unavailable",
|
||||
"error": "Error",
|
||||
"offline": "Offline"
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.4.4",
|
||||
"bluetooth-data-tools==1.23.4",
|
||||
"dbus-fast==2.33.0",
|
||||
"habluetooth==3.24.0"
|
||||
"habluetooth==3.24.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"default": "Default",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"error": "Error",
|
||||
"complete": "Complete",
|
||||
"fully_charged": "Fully charged",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Burbank Water and Power (BWP)."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "burbank_water_and_power",
|
||||
"name": "Burbank Water and Power (BWP)",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.10.0"],
|
||||
"requirements": ["aiostreammagic==2.11.0"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.1.1",
|
||||
"aiodiscover==2.6.1",
|
||||
"cached-ipaddress==0.8.1"
|
||||
"cached-ipaddress==0.9.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -360,9 +360,9 @@
|
||||
"acb_battery_state": {
|
||||
"name": "Battery state",
|
||||
"state": {
|
||||
"discharging": "Discharging",
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ from .const import (
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_NOISE_PSK,
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
DEFAULT_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
DOMAIN,
|
||||
@@ -508,6 +509,10 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
@@ -5,6 +5,7 @@ from awesomeversion import AwesomeVersion
|
||||
DOMAIN = "esphome"
|
||||
|
||||
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
|
||||
CONF_SUBSCRIBE_LOGS = "subscribe_logs"
|
||||
CONF_DEVICE_NAME = "device_name"
|
||||
CONF_NOISE_PSK = "noise_psk"
|
||||
|
||||
@@ -12,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2023.8.0"
|
||||
STABLE_BLE_VERSION_STR = "2025.2.1"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -16,6 +17,7 @@ from aioesphomeapi import (
|
||||
HomeassistantServiceCall,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
ReconnectLogic,
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
@@ -33,6 +35,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
@@ -61,6 +64,7 @@ from .bluetooth import async_connect_scanner
|
||||
from .const import (
|
||||
CONF_ALLOW_SERVICE_CALLS,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_SUBSCRIBE_LOGS,
|
||||
DEFAULT_ALLOW_SERVICE_CALLS,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
@@ -74,8 +78,38 @@ from .domain_data import DomainData
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
SubscribeLogsResponse,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LOG_LEVEL_TO_LOGGER = {
|
||||
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
|
||||
LogLevel.LOG_LEVEL_WARN: logging.WARNING,
|
||||
LogLevel.LOG_LEVEL_INFO: logging.INFO,
|
||||
LogLevel.LOG_LEVEL_CONFIG: logging.INFO,
|
||||
LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG,
|
||||
}
|
||||
LOGGER_TO_LOG_LEVEL = {
|
||||
logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE,
|
||||
logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE,
|
||||
logging.INFO: LogLevel.LOG_LEVEL_CONFIG,
|
||||
logging.WARNING: LogLevel.LOG_LEVEL_WARN,
|
||||
logging.ERROR: LogLevel.LOG_LEVEL_ERROR,
|
||||
logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR,
|
||||
}
|
||||
# 7-bit and 8-bit C1 ANSI sequences
|
||||
# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
|
||||
ANSI_ESCAPE_78BIT = re.compile(
|
||||
rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])"
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_check_firmware_version(
|
||||
@@ -136,6 +170,8 @@ class ESPHomeManager:
|
||||
"""Class to manage an ESPHome connection."""
|
||||
|
||||
__slots__ = (
|
||||
"_cancel_subscribe_logs",
|
||||
"_log_level",
|
||||
"cli",
|
||||
"device_id",
|
||||
"domain_data",
|
||||
@@ -169,6 +205,8 @@ class ESPHomeManager:
|
||||
self.reconnect_logic: ReconnectLogic | None = None
|
||||
self.zeroconf_instance = zeroconf_instance
|
||||
self.entry_data = entry.runtime_data
|
||||
self._cancel_subscribe_logs: CALLBACK_TYPE | None = None
|
||||
self._log_level = LogLevel.LOG_LEVEL_NONE
|
||||
|
||||
async def on_stop(self, event: Event) -> None:
|
||||
"""Cleanup the socket client on HA close."""
|
||||
@@ -341,6 +379,34 @@ class ESPHomeManager:
|
||||
# Re-connection logic will trigger after this
|
||||
await self.cli.disconnect()
|
||||
|
||||
def _async_on_log(self, msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a log message from the API."""
|
||||
log: bytes = msg.message
|
||||
_LOGGER.log(
|
||||
LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
|
||||
"%s: %s",
|
||||
self.entry.title,
|
||||
ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_get_equivalent_log_level(self) -> LogLevel:
|
||||
"""Get the equivalent ESPHome log level for the current logger."""
|
||||
return LOGGER_TO_LOG_LEVEL.get(
|
||||
_LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_subscribe_logs(self, log_level: LogLevel) -> None:
|
||||
"""Subscribe to logs."""
|
||||
if self._cancel_subscribe_logs is not None:
|
||||
self._cancel_subscribe_logs()
|
||||
self._cancel_subscribe_logs = None
|
||||
self._log_level = log_level
|
||||
self._cancel_subscribe_logs = self.cli.subscribe_logs(
|
||||
self._async_on_log, self._log_level
|
||||
)
|
||||
|
||||
async def _on_connnect(self) -> None:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
entry = self.entry
|
||||
@@ -352,6 +418,8 @@ class ESPHomeManager:
|
||||
cli = self.cli
|
||||
stored_device_name = entry.data.get(CONF_DEVICE_NAME)
|
||||
unique_id_is_mac_address = unique_id and ":" in unique_id
|
||||
if entry.options.get(CONF_SUBSCRIBE_LOGS):
|
||||
self._async_subscribe_logs(self._async_get_equivalent_log_level())
|
||||
results = await asyncio.gather(
|
||||
create_eager_task(cli.device_info()),
|
||||
create_eager_task(cli.list_entities_services()),
|
||||
@@ -503,6 +571,10 @@ class ESPHomeManager:
|
||||
def _async_handle_logging_changed(self, _event: Event) -> None:
|
||||
"""Handle when the logging level changes."""
|
||||
self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
|
||||
if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != (
|
||||
new_log_level := self._async_get_equivalent_log_level()
|
||||
):
|
||||
self._async_subscribe_logs(new_log_level)
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start the esphome connection manager."""
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.1.1",
|
||||
"aioesphomeapi==29.2.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.7.1"
|
||||
"bleak-esphome==2.8.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"allow_service_calls": "Allow the device to perform Home Assistant actions."
|
||||
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
|
||||
"subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera):
|
||||
if camera_password:
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if on."""
|
||||
|
||||
@@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
|
||||
"""Return coordinator data for this entity."""
|
||||
return self.coordinator.data[self._serial]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
|
||||
class EzvizBaseEntity(Entity):
|
||||
"""Generic entity for EZVIZ individual poll entities."""
|
||||
@@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity):
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return coordinator data for this entity."""
|
||||
return self.coordinator.data[self._serial]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.data["status"] != 2
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from propcache.api import cached_property
|
||||
from pyezviz.exceptions import PyEzvizError
|
||||
from pyezviz.utils import decrypt_image
|
||||
|
||||
@@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
|
||||
else None
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def available(self) -> bool:
|
||||
"""Entity gets data from ezviz API so always available."""
|
||||
return True
|
||||
|
||||
async def _async_load_image_from_url(self, url: str) -> Image | None:
|
||||
"""Load an image by url."""
|
||||
if response := await self._fetch_url(url):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@home-assistant/frontend"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
@@ -21,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250221.0"]
|
||||
"requirements": ["home-assistant-frontend==20250227.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Callable
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
|
||||
except GoogleDriveApiError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
@@ -58,15 +62,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleDriveConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
||||
hass.loop.call_soon(_async_notify_backup_listeners, hass)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from PIL import Image
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -26,6 +24,7 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -38,6 +37,7 @@ from .const import (
|
||||
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
CONF_IMAGE_FILENAME = "image_filename"
|
||||
CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
@@ -50,31 +50,43 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def generate_content(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
|
||||
if call.data[CONF_IMAGE_FILENAME]:
|
||||
# Deprecated in 2025.3, to remove in 2025.9
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_image_filename_parameter",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_image_filename_parameter",
|
||||
)
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
def append_images_to_prompt():
|
||||
image_filenames = call.data[CONF_IMAGE_FILENAME]
|
||||
for image_filename in image_filenames:
|
||||
if not hass.config.is_allowed_path(image_filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{image_filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
if not Path(image_filename).exists():
|
||||
raise HomeAssistantError(f"`{image_filename}` does not exist")
|
||||
mime_type, _ = mimetypes.guess_type(image_filename)
|
||||
if mime_type is None or not mime_type.startswith("image"):
|
||||
raise HomeAssistantError(f"`{image_filename}` is not an image")
|
||||
prompt_parts.append(Image.open(image_filename))
|
||||
|
||||
await hass.async_add_executor_job(append_images_to_prompt)
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
|
||||
DOMAIN
|
||||
)[0]
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
def append_files_to_prompt():
|
||||
image_filenames = call.data[CONF_IMAGE_FILENAME]
|
||||
filenames = call.data[CONF_FILENAMES]
|
||||
for filename in set(image_filenames + filenames):
|
||||
if not hass.config.is_allowed_path(filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
if not Path(filename).exists():
|
||||
raise HomeAssistantError(f"`{filename}` does not exist")
|
||||
prompt_parts.append(client.files.upload(file=filename))
|
||||
|
||||
await hass.async_add_executor_job(append_files_to_prompt)
|
||||
|
||||
try:
|
||||
response = await client.aio.models.generate_content(
|
||||
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
|
||||
@@ -105,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
|
||||
@@ -9,3 +9,8 @@ generate_content:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
filenames:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
@@ -56,10 +56,21 @@
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "Image filename",
|
||||
"description": "Images",
|
||||
"description": "Deprecated. Use filenames instead.",
|
||||
"example": "/config/www/image.jpg"
|
||||
},
|
||||
"filenames": {
|
||||
"name": "Attachment filenames",
|
||||
"description": "Attachments to add to the prompt (images, PDFs, etc)",
|
||||
"example": "/config/www/image.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_image_filename_parameter": {
|
||||
"title": "Deprecated 'image_filename' parameter",
|
||||
"description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
"""Set light color in kelvin."""
|
||||
await device.set_temperature(temperature)
|
||||
|
||||
async def set_scene(self, device: GoveeController, scene: str) -> None:
|
||||
"""Set light scene."""
|
||||
await device.set_scene(scene)
|
||||
|
||||
@property
|
||||
def devices(self) -> list[GoveeDevice]:
|
||||
"""Return a list of discovered Govee devices."""
|
||||
|
||||
@@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_NONE_SCENE = "none"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -50,10 +54,22 @@ async def async_setup_entry(
|
||||
class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
"""Govee Light."""
|
||||
|
||||
_attr_translation_key = "govee_light"
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes: set[ColorMode]
|
||||
_fixed_color_mode: ColorMode | None = None
|
||||
_attr_effect_list: list[str] | None = None
|
||||
_attr_effect: str | None = None
|
||||
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
|
||||
_last_color_state: (
|
||||
tuple[
|
||||
ColorMode | str | None,
|
||||
int | None,
|
||||
tuple[int, int, int] | tuple[int | None] | None,
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
|
||||
if (
|
||||
GoveeLightFeatures.SCENES & capabilities.features
|
||||
and capabilities.scenes
|
||||
):
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()]
|
||||
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
if len(self._attr_supported_color_modes) == 1:
|
||||
# If the light supports only a single color mode, set it now
|
||||
@@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
self._attr_color_mode = ColorMode.RGB
|
||||
self._attr_effect = None
|
||||
self._last_color_state = None
|
||||
red, green, blue = kwargs[ATTR_RGB_COLOR]
|
||||
await self.coordinator.set_rgb_color(self._device, red, green, blue)
|
||||
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_effect = None
|
||||
self._last_color_state = None
|
||||
temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
await self.coordinator.set_temperature(self._device, int(temperature))
|
||||
elif ATTR_EFFECT in kwargs:
|
||||
effect = kwargs[ATTR_EFFECT]
|
||||
if effect and self._attr_effect_list and effect in self._attr_effect_list:
|
||||
if effect == _NONE_SCENE:
|
||||
self._attr_effect = None
|
||||
await self._restore_last_color_state()
|
||||
else:
|
||||
self._attr_effect = effect
|
||||
self._save_last_color_state()
|
||||
await self.coordinator.set_scene(self._device, effect)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
@callback
|
||||
def _update_callback(self, device: GoveeDevice) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _save_last_color_state(self) -> None:
|
||||
color_mode = self.color_mode
|
||||
self._last_color_state = (
|
||||
color_mode,
|
||||
self.brightness,
|
||||
(self.color_temp_kelvin,)
|
||||
if color_mode == ColorMode.COLOR_TEMP
|
||||
else self.rgb_color,
|
||||
)
|
||||
|
||||
async def _restore_last_color_state(self) -> None:
|
||||
if self._last_color_state:
|
||||
color_mode, brightness, color = self._last_color_state
|
||||
if color:
|
||||
if color_mode == ColorMode.RGB:
|
||||
await self.coordinator.set_rgb_color(self._device, *color)
|
||||
elif color_mode == ColorMode.COLOR_TEMP:
|
||||
await self.coordinator.set_temperature(self._device, *color)
|
||||
if brightness:
|
||||
await self.coordinator.set_brightness(
|
||||
self._device, int((float(brightness) / 255.0) * 100.0)
|
||||
)
|
||||
self._last_color_state = None
|
||||
|
||||
@@ -9,5 +9,29 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"govee_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"none": "None",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"movie": "Movie",
|
||||
"dating": "Dating",
|
||||
"romantic": "Romantic",
|
||||
"twinkle": "Twinkle",
|
||||
"candlelight": "Candlelight",
|
||||
"snowflake": "Snowflake",
|
||||
"energetic": "Energetic",
|
||||
"breathe": "Breathe",
|
||||
"crossing": "Crossing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ ATTR_TYPE = "type"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_KEYWORD = "keyword"
|
||||
ATTR_REMOVE_TAG = "remove_tag"
|
||||
ATTR_ALIAS = "alias"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_COST = "cost"
|
||||
ATTR_NOTES = "notes"
|
||||
|
||||
SERVICE_CAST_SKILL = "cast_skill"
|
||||
SERVICE_START_QUEST = "start_quest"
|
||||
@@ -50,6 +55,7 @@ SERVICE_SCORE_REWARD = "score_reward"
|
||||
|
||||
SERVICE_TRANSFORMATION = "transformation"
|
||||
|
||||
SERVICE_UPDATE_REWARD = "update_reward"
|
||||
|
||||
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
|
||||
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
|
||||
|
||||
@@ -217,6 +217,13 @@
|
||||
"sections": {
|
||||
"filter": "mdi:calendar-filter"
|
||||
}
|
||||
},
|
||||
"update_reward": {
|
||||
"service": "mdi:treasure-chest",
|
||||
"sections": {
|
||||
"tag_options": "mdi:tag",
|
||||
"developer_options": "mdi:test-tube"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
@@ -13,6 +14,7 @@ from habiticalib import (
|
||||
NotAuthorizedError,
|
||||
NotFoundError,
|
||||
Skill,
|
||||
Task,
|
||||
TaskData,
|
||||
TaskPriority,
|
||||
TaskType,
|
||||
@@ -20,6 +22,7 @@ from habiticalib import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import ATTR_RENAME
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_NAME, CONF_NAME
|
||||
from homeassistant.core import (
|
||||
@@ -34,14 +37,18 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
|
||||
from .const import (
|
||||
ATTR_ALIAS,
|
||||
ATTR_ARGS,
|
||||
ATTR_CONFIG_ENTRY,
|
||||
ATTR_COST,
|
||||
ATTR_DATA,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_ITEM,
|
||||
ATTR_KEYWORD,
|
||||
ATTR_NOTES,
|
||||
ATTR_PATH,
|
||||
ATTR_PRIORITY,
|
||||
ATTR_REMOVE_TAG,
|
||||
ATTR_SKILL,
|
||||
ATTR_TAG,
|
||||
ATTR_TARGET,
|
||||
@@ -61,6 +68,7 @@ from .const import (
|
||||
SERVICE_SCORE_REWARD,
|
||||
SERVICE_START_QUEST,
|
||||
SERVICE_TRANSFORMATION,
|
||||
SERVICE_UPDATE_REWARD,
|
||||
)
|
||||
from .coordinator import HabiticaConfigEntry
|
||||
|
||||
@@ -104,6 +112,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_UPDATE_TASK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
vol.Required(ATTR_TASK): cv.string,
|
||||
vol.Optional(ATTR_RENAME): cv.string,
|
||||
vol.Optional(ATTR_NOTES): cv.string,
|
||||
vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_ALIAS): vol.All(
|
||||
cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$")
|
||||
),
|
||||
vol.Optional(ATTR_COST): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
@@ -516,6 +539,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
|
||||
return result
|
||||
|
||||
async def update_task(call: ServiceCall) -> ServiceResponse:
|
||||
"""Update task action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.async_refresh()
|
||||
|
||||
try:
|
||||
current_task = next(
|
||||
task
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
and task.Type is TaskType.REWARD
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="task_not_found",
|
||||
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
|
||||
) from e
|
||||
|
||||
task_id = current_task.id
|
||||
if TYPE_CHECKING:
|
||||
assert task_id
|
||||
data = Task()
|
||||
|
||||
if rename := call.data.get(ATTR_RENAME):
|
||||
data["text"] = rename
|
||||
|
||||
if (notes := call.data.get(ATTR_NOTES)) is not None:
|
||||
data["notes"] = notes
|
||||
|
||||
tags = cast(list[str], call.data.get(ATTR_TAG))
|
||||
remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG))
|
||||
|
||||
if tags or remove_tags:
|
||||
update_tags = set(current_task.tags)
|
||||
user_tags = {
|
||||
tag.name.lower(): tag.id
|
||||
for tag in coordinator.data.user.tags
|
||||
if tag.id and tag.name
|
||||
}
|
||||
|
||||
if tags:
|
||||
# Creates new tag if it doesn't exist
|
||||
async def create_tag(tag_name: str) -> UUID:
|
||||
tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id
|
||||
if TYPE_CHECKING:
|
||||
assert tag_id
|
||||
return tag_id
|
||||
|
||||
try:
|
||||
update_tags.update(
|
||||
{
|
||||
user_tags.get(tag_name.lower())
|
||||
or (await create_tag(tag_name))
|
||||
for tag_name in tags
|
||||
}
|
||||
)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||
) from e
|
||||
except HabiticaException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e.error.message)},
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
|
||||
if remove_tags:
|
||||
update_tags.difference_update(
|
||||
{
|
||||
user_tags[tag_name.lower()]
|
||||
for tag_name in remove_tags
|
||||
if tag_name.lower() in user_tags
|
||||
}
|
||||
)
|
||||
|
||||
data["tags"] = list(update_tags)
|
||||
|
||||
if (alias := call.data.get(ATTR_ALIAS)) is not None:
|
||||
data["alias"] = alias
|
||||
|
||||
if (cost := call.data.get(ATTR_COST)) is not None:
|
||||
data["value"] = cost
|
||||
|
||||
try:
|
||||
response = await coordinator.habitica.update_task(task_id, data)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||
) from e
|
||||
except HabiticaException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e.error.message)},
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
else:
|
||||
return response.data.to_dict(omit_none=True)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_REWARD,
|
||||
update_task,
|
||||
schema=SERVICE_UPDATE_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_API_CALL,
|
||||
|
||||
@@ -140,3 +140,43 @@ get_tasks:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
update_reward:
|
||||
fields:
|
||||
config_entry: *config_entry
|
||||
task: *task
|
||||
rename:
|
||||
selector:
|
||||
text:
|
||||
notes:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
cost:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 0.01
|
||||
unit_of_measurement: "🪙"
|
||||
mode: box
|
||||
tag_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
remove_tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
developer_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
alias:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -7,7 +7,23 @@
|
||||
"unit_tasks": "tasks",
|
||||
"unit_health_points": "HP",
|
||||
"unit_mana_points": "MP",
|
||||
"unit_experience_points": "XP"
|
||||
"unit_experience_points": "XP",
|
||||
"config_entry_description": "Select the Habitica account to update a task.",
|
||||
"task_description": "The name (or task ID) of the task you want to update.",
|
||||
"rename_name": "Rename",
|
||||
"rename_description": "The new title for the Habitica task.",
|
||||
"notes_name": "Update notes",
|
||||
"notes_description": "The new notes for the Habitica task.",
|
||||
"tag_name": "Add tags",
|
||||
"tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.",
|
||||
"remove_tag_name": "Remove tags",
|
||||
"remove_tag_description": "Remove tags from the Habitica task.",
|
||||
"alias_name": "Task alias",
|
||||
"alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.",
|
||||
"developer_options_name": "Advanced settings",
|
||||
"developer_options_description": "Additional features available in developer mode.",
|
||||
"tag_options_name": "Tags",
|
||||
"tag_options_description": "Add or remove tags from a task."
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
@@ -457,6 +473,12 @@
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
|
||||
},
|
||||
"frequency_not_weekly": {
|
||||
"message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies."
|
||||
},
|
||||
"frequency_not_monthly": {
|
||||
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -651,6 +673,54 @@
|
||||
"description": "Use the optional filters to narrow the returned tasks."
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_reward": {
|
||||
"name": "Update a reward",
|
||||
"description": "Updates a specific reward for the selected Habitica character",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
"description": "Select the Habitica account to update a reward."
|
||||
},
|
||||
"task": {
|
||||
"name": "[%key:component::habitica::common::task_name%]",
|
||||
"description": "[%key:component::habitica::common::task_description%]"
|
||||
},
|
||||
"rename": {
|
||||
"name": "[%key:component::habitica::common::rename_name%]",
|
||||
"description": "[%key:component::habitica::common::rename_description%]"
|
||||
},
|
||||
"notes": {
|
||||
"name": "[%key:component::habitica::common::notes_name%]",
|
||||
"description": "[%key:component::habitica::common::notes_description%]"
|
||||
},
|
||||
"tag": {
|
||||
"name": "[%key:component::habitica::common::tag_name%]",
|
||||
"description": "[%key:component::habitica::common::tag_description%]"
|
||||
},
|
||||
"remove_tag": {
|
||||
"name": "[%key:component::habitica::common::remove_tag_name%]",
|
||||
"description": "[%key:component::habitica::common::remove_tag_description%]"
|
||||
},
|
||||
"alias": {
|
||||
"name": "[%key:component::habitica::common::alias_name%]",
|
||||
"description": "[%key:component::habitica::common::alias_description%]"
|
||||
},
|
||||
"cost": {
|
||||
"name": "Cost",
|
||||
"description": "Update the cost of a reward."
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"tag_options": {
|
||||
"name": "[%key:component::habitica::common::tag_options_name%]",
|
||||
"description": "[%key:component::habitica::common::tag_options_description%]"
|
||||
},
|
||||
"developer_options": {
|
||||
"name": "[%key:component::habitica::common::developer_options_name%]",
|
||||
"description": "[%key:component::habitica::common::developer_options_description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
"""Move an item in the To-do list."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.todo_items
|
||||
tasks_order = (
|
||||
self.coordinator.data.user.tasksOrder.todos
|
||||
if self.entity_description.key is HabiticaTodoList.TODOS
|
||||
else self.coordinator.data.user.tasksOrder.dailys
|
||||
)
|
||||
|
||||
if previous_uid:
|
||||
pos = self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == previous_uid)
|
||||
)
|
||||
if pos < self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == uid)
|
||||
):
|
||||
pos = tasks_order.index(UUID(previous_uid))
|
||||
if pos < tasks_order.index(UUID(uid)):
|
||||
pos += 1
|
||||
|
||||
else:
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
tasks_order[:] = (
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
).data
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
translation_key=f"move_{self.entity_description.key}_item_failed",
|
||||
translation_placeholders={"pos": str(pos)},
|
||||
) from e
|
||||
else:
|
||||
# move tasks in the coordinator until we have fresh data
|
||||
tasks = self.coordinator.data.tasks
|
||||
new_pos = (
|
||||
tasks.index(
|
||||
next(task for task in tasks if task.id == UUID(previous_uid))
|
||||
)
|
||||
+ 1
|
||||
if previous_uid
|
||||
else 0
|
||||
)
|
||||
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
|
||||
tasks.insert(new_pos, tasks.pop(old_pos))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update a Habitica todo."""
|
||||
@@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
def todo_items(self) -> list[TodoItem]:
|
||||
"""Return the todo items."""
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.TODO
|
||||
),
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a Habitica todo."""
|
||||
@@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert self.coordinator.data.user.lastCron
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.DAILY
|
||||
)
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -45,13 +45,13 @@ from homeassistant.components.backup import (
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
async_get_manager as async_get_backup_manager,
|
||||
suggested_filename as suggested_backup_filename,
|
||||
suggested_filename_from_name_date,
|
||||
)
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -751,7 +751,7 @@ async def backup_addon_before_update(
|
||||
|
||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||
"""Prepare for updating core."""
|
||||
backup_manager = async_get_backup_manager(hass)
|
||||
backup_manager = await async_get_backup_manager(hass)
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
|
||||
@@ -203,7 +203,13 @@ async def _get_client_and_ha_id(
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError("Device entry not found for device id")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_entry_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
entry: HomeConnectConfigEntry | None = None
|
||||
for entry_id in device_entry.config_entries:
|
||||
_entry = hass.config_entries.async_get_entry(entry_id)
|
||||
@@ -213,7 +219,11 @@ async def _get_client_and_ha_id(
|
||||
break
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
"Home Connect config entry not found for that device id"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
|
||||
ha_id = next(
|
||||
@@ -405,6 +415,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_command_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_command_actions",
|
||||
)
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
@@ -610,6 +631,7 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_command_actions")
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ from .coordinator import (
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
REFRIGERATION_DOOR_BOOLEAN_MAP = {
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED: False,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN: True,
|
||||
|
||||
@@ -18,6 +18,8 @@ from .coordinator import (
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Home Connect button entity."""
|
||||
|
||||
@@ -72,22 +72,27 @@ def _handle_paired_or_connected_appliance(
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(appliance.info.ha_id, event_key),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
|
||||
|
||||
from .utils import bsh_key_to_translation_key
|
||||
|
||||
DOMAIN = "home_connect"
|
||||
@@ -21,6 +23,13 @@ APPLIANCES_WITH_PROGRAMS = (
|
||||
"WasherDryer",
|
||||
)
|
||||
|
||||
UNIT_MAP = {
|
||||
"seconds": UnitOfTime.SECONDS,
|
||||
"ml": UnitOfVolume.MILLILITERS,
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
|
||||
|
||||
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
|
||||
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
|
||||
|
||||
@@ -440,13 +440,27 @@ class HomeConnectCoordinator(
|
||||
self, ha_id: str, program_key: ProgramKey
|
||||
) -> dict[OptionKey, ProgramDefinitionOption]:
|
||||
"""Get options with constraints for appliance."""
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(ha_id, program_key=program_key)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
if program_key is ProgramKey.UNKNOWN:
|
||||
return {}
|
||||
try:
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(
|
||||
ha_id, program_key=program_key
|
||||
)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching options for %s: %s",
|
||||
ha_id,
|
||||
error
|
||||
if isinstance(error, HomeConnectApiError)
|
||||
else type(error).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
async def update_options(
|
||||
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
|
||||
@@ -456,8 +470,7 @@ class HomeConnectCoordinator(
|
||||
events = self.data[ha_id].events
|
||||
options_to_notify = options.copy()
|
||||
options.clear()
|
||||
if program_key is not ProgramKey.UNKNOWN:
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
|
||||
for option in options.values():
|
||||
option_value = option.constraints.default if option.constraints else None
|
||||
|
||||
@@ -36,6 +36,8 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectLightEntityDescription(LightEntityDescription):
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.12.3"],
|
||||
"requirements": ["aiohomeconnect==0.15.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -23,6 +22,7 @@ from .const import (
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
@@ -30,12 +30,7 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UNIT_MAP = {
|
||||
"seconds": UnitOfTime.SECONDS,
|
||||
"ml": UnitOfVolume.MILLILITERS,
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
NUMBERS = (
|
||||
NumberEntityDescription(
|
||||
|
||||
@@ -50,6 +50,8 @@ from .coordinator import (
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Provides a sensor for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, StatusKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -23,10 +25,13 @@ from .const import (
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
||||
|
||||
|
||||
@@ -38,6 +43,7 @@ class HomeConnectSensorEntityDescription(
|
||||
|
||||
default_value: str | None = None
|
||||
appliance_types: tuple[str, ...] | None = None
|
||||
fetch_unit: bool = False
|
||||
|
||||
|
||||
BSH_PROGRAM_SENSORS = (
|
||||
@@ -177,6 +183,13 @@ SENSORS = (
|
||||
],
|
||||
translation_key="last_selected_map",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="oven_current_cavity_temperature",
|
||||
fetch_unit=True,
|
||||
),
|
||||
)
|
||||
|
||||
EVENT_SENSORS = (
|
||||
@@ -310,6 +323,29 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
case _:
|
||||
self._attr_native_value = status
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if self.entity_description.fetch_unit:
|
||||
data = self.appliance.status[cast(StatusKey, self.bsh_key)]
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
data.unit, data.unit
|
||||
)
|
||||
else:
|
||||
await self.fetch_unit()
|
||||
|
||||
async def fetch_unit(self) -> None:
|
||||
"""Fetch the unit of measurement."""
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
data = await self.coordinator.client.get_status_value(
|
||||
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
|
||||
)
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
data.unit, data.unit
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
"""Sensor class for Home Connect sensors that reports information related to the running program."""
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
"appliance_not_found": {
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
},
|
||||
"device_entry_not_found": {
|
||||
"message": "Device entry for device ID {device_id} not found"
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry for device ID {device_id} not found"
|
||||
},
|
||||
"turn_on_light": {
|
||||
"message": "Error turning on {entity_id}: {error}"
|
||||
},
|
||||
@@ -108,6 +114,10 @@
|
||||
"title": "Deprecated binary door sensor detected in some automations or scripts",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
},
|
||||
"deprecated_program_switch": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
@@ -1519,6 +1529,9 @@
|
||||
"map3": "Map 3"
|
||||
}
|
||||
},
|
||||
"oven_current_cavity_temperature": {
|
||||
"name": "Current oven cavity temperature"
|
||||
},
|
||||
"freezer_door_alarm": {
|
||||
"name": "Freezer door alarm",
|
||||
"state": {
|
||||
|
||||
@@ -42,6 +42,7 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
SWITCHES = (
|
||||
SwitchEntityDescription(
|
||||
|
||||
@@ -23,6 +23,8 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
TIME_ENTITIES = (
|
||||
TimeEntityDescription(
|
||||
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
|
||||
@@ -437,18 +437,21 @@ def ws_expose_entity(
|
||||
def ws_list_exposed_entities(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant."""
|
||||
"""List entities which are exposed to assistants."""
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
|
||||
result[entity_id] = {}
|
||||
exposed_to = {}
|
||||
entity_settings = async_get_entity_settings(hass, entity_id)
|
||||
for assistant, settings in entity_settings.items():
|
||||
if "should_expose" not in settings:
|
||||
if "should_expose" not in settings or not settings["should_expose"]:
|
||||
continue
|
||||
result[entity_id][assistant] = settings["should_expose"]
|
||||
exposed_to[assistant] = True
|
||||
if not exposed_to:
|
||||
continue
|
||||
result[entity_id] = exposed_to
|
||||
connection.send_result(msg["id"], {"exposed_entities": result})
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
type HomeeConfigEntry = ConfigEntry[Homee]
|
||||
|
||||
@@ -75,4 +75,4 @@ class HomeeButton(HomeeEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.async_set_value(1)
|
||||
await self.async_set_homee_value(1)
|
||||
|
||||
@@ -205,17 +205,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Open the cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -230,12 +230,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(attribute, homee_position)
|
||||
await self.async_set_homee_value(attribute, homee_position)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
await self.async_set_value(self._open_close_attribute, 2)
|
||||
await self.async_set_homee_value(self._open_close_attribute, 2)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
@@ -245,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
else:
|
||||
await self.async_set_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
@@ -257,9 +257,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
else:
|
||||
await self.async_set_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
@@ -276,4 +276,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(attribute, homee_position)
|
||||
await self.async_set_homee_value(attribute, homee_position)
|
||||
|
||||
@@ -54,7 +54,7 @@ class HomeeEntity(Entity):
|
||||
"""Return the availability of the underlying node."""
|
||||
return (self._attribute.state == AttributeState.NORMAL) and self._host_connected
|
||||
|
||||
async def async_set_value(self, value: float) -> None:
|
||||
async def async_set_homee_value(self, value: float) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
homee = self._entry.runtime_data
|
||||
try:
|
||||
@@ -144,7 +144,9 @@ class HomeeNodeEntity(Entity):
|
||||
|
||||
return None
|
||||
|
||||
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
||||
async def async_set_homee_value(
|
||||
self, attribute: HomeeAttribute, value: float
|
||||
) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
homee = self._entry.runtime_data
|
||||
try:
|
||||
|
||||
@@ -175,24 +175,26 @@ class HomeeLight(HomeeNodeEntity, LightEntity):
|
||||
kwargs[ATTR_BRIGHTNESS],
|
||||
)
|
||||
)
|
||||
await self.async_set_value(self._dimmer_attr, target_value)
|
||||
await self.async_set_homee_value(self._dimmer_attr, target_value)
|
||||
else:
|
||||
# If no brightness value is given, just turn on.
|
||||
await self.async_set_value(self._on_off_attr, 1)
|
||||
await self.async_set_homee_value(self._on_off_attr, 1)
|
||||
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None:
|
||||
await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN])
|
||||
await self.async_set_homee_value(
|
||||
self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
color = kwargs[ATTR_HS_COLOR]
|
||||
if self._col_attr is not None:
|
||||
await self.async_set_value(
|
||||
await self.async_set_homee_value(
|
||||
self._col_attr,
|
||||
rgb_list_to_decimal(color_hs_to_RGB(*color)),
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
await self.async_set_value(self._on_off_attr, 0)
|
||||
await self.async_set_homee_value(self._on_off_attr, 0)
|
||||
|
||||
def _get_supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Determine the supported color modes from the available attributes."""
|
||||
|
||||
130
homeassistant/components/homee/number.py
Normal file
130
homeassistant/components/homee/number.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""The Homee number platform."""
|
||||
|
||||
from pyHomee.const import AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .const import HOMEE_UNIT_TO_HA_UNIT
|
||||
from .entity import HomeeEntity
|
||||
|
||||
NUMBER_DESCRIPTIONS = {
|
||||
AttributeType.DOWN_POSITION: NumberEntityDescription(
|
||||
key="down_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription(
|
||||
key="down_slat_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_TIME: NumberEntityDescription(
|
||||
key="down_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription(
|
||||
key="endposition_configuration",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription(
|
||||
key="motion_alarm_cancelation_delay",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription(
|
||||
key="open_window_detection_sensibility",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.POLLING_INTERVAL: NumberEntityDescription(
|
||||
key="polling_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription(
|
||||
key="shutter_slat_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription(
|
||||
key="slat_max_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription(
|
||||
key="slat_min_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_STEPS: NumberEntityDescription(
|
||||
key="slat_steps",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription(
|
||||
key="temperature_offset",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.UP_TIME: NumberEntityDescription(
|
||||
key="up_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription(
|
||||
key="wake_up_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the number component."""
|
||||
|
||||
async_add_entities(
|
||||
HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
|
||||
)
|
||||
|
||||
|
||||
class HomeeNumber(HomeeEntity, NumberEntity):
|
||||
"""Representation of a Homee number."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: NumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee number entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_translation_key = description.key
|
||||
self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
|
||||
self._attr_native_min_value = attribute.minimum
|
||||
self._attr_native_max_value = attribute.maximum
|
||||
self._attr_native_step = attribute.step_value
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the entity."""
|
||||
return super().available and self._attribute.editable
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the native value of the number."""
|
||||
return int(self._attribute.current_value)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the selected value."""
|
||||
await self.async_set_homee_value(value)
|
||||
@@ -66,6 +66,50 @@
|
||||
"name": "Light {instance}"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"down_position": {
|
||||
"name": "Down position"
|
||||
},
|
||||
"down_slat_position": {
|
||||
"name": "Down slat position"
|
||||
},
|
||||
"down_time": {
|
||||
"name": "Down-movement duration"
|
||||
},
|
||||
"endposition_configuration": {
|
||||
"name": "End position"
|
||||
},
|
||||
"motion_alarm_cancelation_delay": {
|
||||
"name": "Motion alarm delay"
|
||||
},
|
||||
"open_window_detection_sensibility": {
|
||||
"name": "Window open sensibility"
|
||||
},
|
||||
"polling_interval": {
|
||||
"name": "Polling interval"
|
||||
},
|
||||
"shutter_slat_time": {
|
||||
"name": "Slat turn duration"
|
||||
},
|
||||
"slat_max_angle": {
|
||||
"name": "Maximum slat angle"
|
||||
},
|
||||
"slat_min_angle": {
|
||||
"name": "Minimum slat angle"
|
||||
},
|
||||
"slat_steps": {
|
||||
"name": "Slat steps"
|
||||
},
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
"up_time": {
|
||||
"name": "Up-movement duration"
|
||||
},
|
||||
"wake_up_interval": {
|
||||
"name": "Wake-up interval"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"brightness_instance": {
|
||||
"name": "Illuminance {instance}"
|
||||
@@ -205,6 +249,11 @@
|
||||
"watchdog": {
|
||||
"name": "Watchdog"
|
||||
}
|
||||
},
|
||||
"valve": {
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -120,8 +120,8 @@ class HomeeSwitch(HomeeEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.async_set_value(1)
|
||||
await self.async_set_homee_value(1)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.async_set_value(0)
|
||||
await self.async_set_homee_value(0)
|
||||
|
||||
81
homeassistant/components/homee/valve.py
Normal file
81
homeassistant/components/homee/valve.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""The Homee valve platform."""
|
||||
|
||||
from pyHomee.const import AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ValveDeviceClass,
|
||||
ValveEntity,
|
||||
ValveEntityDescription,
|
||||
ValveEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .entity import HomeeEntity
|
||||
|
||||
VALVE_DESCRIPTIONS = {
|
||||
AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription(
|
||||
key="valve_position",
|
||||
device_class=ValveDeviceClass.WATER,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the valve component."""
|
||||
|
||||
async_add_entities(
|
||||
HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if attribute.type in VALVE_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class HomeeValve(HomeeEntity, ValveEntity):
|
||||
"""Representation of a Homee valve."""
|
||||
|
||||
_attr_reports_position = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: ValveEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee valve entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_translation_key = description.key
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ValveEntityFeature:
|
||||
"""Return the supported features."""
|
||||
if self._attribute.editable:
|
||||
return ValveEntityFeature.SET_POSITION
|
||||
return ValveEntityFeature(0)
|
||||
|
||||
@property
|
||||
def current_valve_position(self) -> int | None:
|
||||
"""Return the current valve position."""
|
||||
return int(self._attribute.current_value)
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the valve is closing."""
|
||||
return self._attribute.target_value < self._attribute.current_value
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
"""Return if the valve is opening."""
|
||||
return self._attribute.target_value > self._attribute.current_value
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
await self.async_set_homee_value(position)
|
||||
@@ -10,7 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==4.9.2",
|
||||
"fnv-hash-fast==1.2.3",
|
||||
"fnv-hash-fast==1.2.6",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"fields": {
|
||||
"cycle": {
|
||||
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]",
|
||||
"description": "[%key:component::input_select::services::select_next::fields::cycle::description%]"
|
||||
"description": "If the option should cycle from the first to the last option on the list."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -296,7 +296,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_1",
|
||||
"statistic_id": "sensor.statistics_issues_issue_1",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -308,7 +308,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_2",
|
||||
"statistic_id": "sensor.statistics_issues_issue_2",
|
||||
"unit_of_measurement": "cats",
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -320,7 +320,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_3",
|
||||
"statistic_id": "sensor.statistics_issues_issue_3",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
@@ -332,7 +332,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
metadata = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issue_4",
|
||||
"statistic_id": "sensor.statistics_issues_issue_4",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"has_sum": False,
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"config_subentries": {
|
||||
"entity": {
|
||||
"title": "Add entity",
|
||||
"step": {
|
||||
"add_sensor": {
|
||||
"description": "Configure the new sensor",
|
||||
@@ -27,7 +26,12 @@
|
||||
"state": "Initial state"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add sensor",
|
||||
"reconfigure": "Reconfigure sensor"
|
||||
},
|
||||
"entry_type": "Sensor"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -83,7 +83,7 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
coordinator.device.model == MachineModel.LINEA_MINI
|
||||
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==1.4.6"]
|
||||
"requirements": ["pylamarzocco==1.4.7"]
|
||||
}
|
||||
|
||||
@@ -220,7 +220,8 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
config.bbw_settings.doses[key] if config.bbw_settings else None
|
||||
),
|
||||
supported_fn=(
|
||||
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
|
||||
lambda coordinator: coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale is not None
|
||||
),
|
||||
),
|
||||
|
||||
@@ -88,6 +88,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
|
||||
MachineModel.GS3_AV,
|
||||
MachineModel.LINEA_MICRA,
|
||||
MachineModel.LINEA_MINI,
|
||||
MachineModel.LINEA_MINI_R,
|
||||
),
|
||||
),
|
||||
LaMarzoccoSelectEntityDescription(
|
||||
@@ -138,7 +139,7 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
coordinator.device.model == MachineModel.LINEA_MINI
|
||||
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -80,7 +80,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
BoilerType.STEAM
|
||||
].current_temperature,
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.LINEA_MINI,
|
||||
not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -125,7 +125,8 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
device.config.scale.battery if device.config.scale else 0
|
||||
),
|
||||
supported_fn=(
|
||||
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
|
||||
lambda coordinator: coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -148,7 +149,8 @@ async def async_setup_entry(
|
||||
]
|
||||
|
||||
if (
|
||||
config_coordinator.device.model == MachineModel.LINEA_MINI
|
||||
config_coordinator.device.model
|
||||
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||
and config_coordinator.device.config.scale
|
||||
):
|
||||
entities.extend(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Iterable
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
from typing import cast
|
||||
|
||||
import pypck
|
||||
|
||||
@@ -18,6 +17,11 @@ from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_SOURCE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
LIGHT_LUX,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -47,6 +51,17 @@ DEVICE_CLASS_MAPPING = {
|
||||
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
|
||||
}
|
||||
|
||||
UNIT_OF_MEASUREMENT_MAPPING = {
|
||||
pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS,
|
||||
pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN,
|
||||
pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
|
||||
pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX,
|
||||
pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX,
|
||||
pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND,
|
||||
pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
|
||||
pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
|
||||
}
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
config_entry: ConfigEntry,
|
||||
@@ -103,8 +118,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
|
||||
config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT]
|
||||
)
|
||||
|
||||
self._attr_native_unit_of_measurement = cast(str, self.unit.value)
|
||||
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None)
|
||||
self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get(
|
||||
self.unit
|
||||
)
|
||||
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"name": "State",
|
||||
"state": {
|
||||
"available": "Available",
|
||||
"charging": "Charging",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"connected": "Connected",
|
||||
"error": "Error",
|
||||
"locked": "Locked",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user