mirror of
https://github.com/home-assistant/core.git
synced 2026-04-18 15:39:12 +02:00
Compare commits
253 Commits
2026.4.0b1
...
infrared_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0455c0c1f | ||
|
|
0807525e1b | ||
|
|
73a86b8606 | ||
|
|
b8652e70e5 | ||
|
|
a3f3b0bed4 | ||
|
|
daaa68ce22 | ||
|
|
9ada10e0cf | ||
|
|
35287c381b | ||
|
|
2ff84b633c | ||
|
|
c09d91765f | ||
|
|
ac6ddf32c8 | ||
|
|
f15d9e5956 | ||
|
|
f95601a2e7 | ||
|
|
0aef0cc121 | ||
|
|
d1bfd94d33 | ||
|
|
8a9c0f4fde | ||
|
|
3596771af1 | ||
|
|
7b9b457f15 | ||
|
|
cb8597d62f | ||
|
|
c82cfaf633 | ||
|
|
80802c9997 | ||
|
|
971579f021 | ||
|
|
af6b8d4f66 | ||
|
|
e9a61963f2 | ||
|
|
b350712f9e | ||
|
|
51785f10c1 | ||
|
|
24e0627b41 | ||
|
|
6c453c8b49 | ||
|
|
904a2d1b4d | ||
|
|
f3b64dcbe0 | ||
|
|
0edc2cbbab | ||
|
|
751f06eb58 | ||
|
|
9bfac71bd7 | ||
|
|
9499476940 | ||
|
|
eda1eb2e35 | ||
|
|
075e179972 | ||
|
|
99e8066607 | ||
|
|
7ce32f0668 | ||
|
|
dc5547d7b6 | ||
|
|
de98bc7dcf | ||
|
|
a71d48085a | ||
|
|
9e20a13936 | ||
|
|
e164e65217 | ||
|
|
07998de35e | ||
|
|
5253dc11dc | ||
|
|
3f9022cd53 | ||
|
|
073f498c75 | ||
|
|
c5b24e9470 | ||
|
|
c12b7bfd18 | ||
|
|
1c2f583587 | ||
|
|
58a376e68b | ||
|
|
78b251e7cb | ||
|
|
a2c65b9126 | ||
|
|
5e443681c3 | ||
|
|
13756863f1 | ||
|
|
fd54e45aeb | ||
|
|
52af74c3b6 | ||
|
|
dc111a475e | ||
|
|
14cb42349a | ||
|
|
c42b50418e | ||
|
|
501b4e6efb | ||
|
|
ca2099b165 | ||
|
|
69b55c295d | ||
|
|
13709b1c90 | ||
|
|
2c013777db | ||
|
|
91099ea489 | ||
|
|
70cea66e5b | ||
|
|
e78bb97e84 | ||
|
|
732b170190 | ||
|
|
0a05993a4e | ||
|
|
42c3610685 | ||
|
|
4ad73da7ec | ||
|
|
0d14bdab24 | ||
|
|
157362f225 | ||
|
|
1aa380fdfa | ||
|
|
9348948afa | ||
|
|
14b9915914 | ||
|
|
607462028b | ||
|
|
8c07348a3d | ||
|
|
cda52af178 | ||
|
|
d1ccda18f7 | ||
|
|
9fb0b69f0a | ||
|
|
f0848edea9 | ||
|
|
5be12a213d | ||
|
|
20b284d0e9 | ||
|
|
49c3376c95 | ||
|
|
174b5f5593 | ||
|
|
b38e41a34a | ||
|
|
b6350478a5 | ||
|
|
b75af6d84a | ||
|
|
194485d863 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea | ||
|
|
970925141e | ||
|
|
51131beaec | ||
|
|
c509226d17 | ||
|
|
067a9a0c25 | ||
|
|
d10197d535 | ||
|
|
8978d197ca | ||
|
|
afc73fdcfd | ||
|
|
31a24446a8 | ||
|
|
e80caaa7cd | ||
|
|
2b3a504a05 | ||
|
|
a93229bd32 | ||
|
|
99306a75d3 | ||
|
|
3a761116e4 | ||
|
|
a6ec59d6a5 | ||
|
|
ca51123115 | ||
|
|
cfc58bd415 | ||
|
|
a18f3cba32 | ||
|
|
6218741602 | ||
|
|
2285db5bb1 | ||
|
|
738b85c17d | ||
|
|
b7bb185d50 | ||
|
|
f4544cf952 | ||
|
|
beab473dcc | ||
|
|
96891228c9 | ||
|
|
a4a36b5cbd | ||
|
|
4a0a400e22 | ||
|
|
fbe4195ae0 | ||
|
|
116fa57903 | ||
|
|
2399da93db | ||
|
|
3850bb0e57 | ||
|
|
f45c84b2a8 | ||
|
|
a2e60f84da | ||
|
|
3757289c73 | ||
|
|
09067a18b7 | ||
|
|
6eb834946b | ||
|
|
0e1663f259 | ||
|
|
0ba3a94a3b | ||
|
|
3562a3800f | ||
|
|
de0efa1639 | ||
|
|
818cf41c22 | ||
|
|
25bfb16936 | ||
|
|
75782e6f17 | ||
|
|
3e5c291338 | ||
|
|
30163fa2e7 | ||
|
|
16231d8d36 | ||
|
|
0c0d6595d6 | ||
|
|
a443060faa | ||
|
|
9807722077 | ||
|
|
12b485b17e | ||
|
|
45def46a45 | ||
|
|
685b921fe7 | ||
|
|
b813aa213f | ||
|
|
79ec3ff484 | ||
|
|
63ba49ce4c | ||
|
|
85c7bf1dff | ||
|
|
894e9bab0a | ||
|
|
b39c83efd2 | ||
|
|
e855b92b82 | ||
|
|
30ee28a0d3 | ||
|
|
78f6b934bb | ||
|
|
fbef3b27bd | ||
|
|
646f56d015 | ||
|
|
f82d21886a | ||
|
|
f5054d41e1 | ||
|
|
53f64bff49 | ||
|
|
65cb9b8528 | ||
|
|
ecd16d759a | ||
|
|
8498e2a715 | ||
|
|
4fa4ba5ad0 | ||
|
|
a953b697ce | ||
|
|
c543743245 | ||
|
|
5b76fab646 | ||
|
|
6153705b61 | ||
|
|
8632420b8f | ||
|
|
4f89715453 | ||
|
|
8ca8c2191f | ||
|
|
cb43950ccf | ||
|
|
ddfef18183 | ||
|
|
ac65ba7d20 | ||
|
|
d76272d74a | ||
|
|
8e5daeb7dd | ||
|
|
5d7abae490 | ||
|
|
f875c77af0 | ||
|
|
c00a68383c | ||
|
|
5544157d5e | ||
|
|
70aa58913d | ||
|
|
cc363e4ebd | ||
|
|
8d28b399b0 | ||
|
|
fe76fe5408 | ||
|
|
a7de418213 | ||
|
|
e359a8952b | ||
|
|
0a9d4ef138 | ||
|
|
5620cfbfd8 | ||
|
|
fb65cf48c9 | ||
|
|
7fd7b2c203 | ||
|
|
69e691f042 | ||
|
|
f690e6de6a | ||
|
|
ee3c2e6f80 | ||
|
|
5ffe301384 | ||
|
|
e5ad6092d1 | ||
|
|
bd79958d10 | ||
|
|
fe485f853f | ||
|
|
3c67c6087a | ||
|
|
cb7f9b5f49 | ||
|
|
2547563e8c | ||
|
|
213b370693 | ||
|
|
2c9ecb394d | ||
|
|
51a5f5793f | ||
|
|
33f11f2263 | ||
|
|
45069b623c | ||
|
|
5defb4dbff | ||
|
|
bc7c3f0617 | ||
|
|
704c0d1eb0 | ||
|
|
6c864a1725 | ||
|
|
299c6556bb | ||
|
|
f0fc98cb66 | ||
|
|
cd63d14e6f | ||
|
|
30dfd23da8 | ||
|
|
d39ef523b8 | ||
|
|
b6c2fbb8c0 | ||
|
|
758d5469aa | ||
|
|
ea99f88d10 | ||
|
|
0a8f76864c | ||
|
|
ad522d723c | ||
|
|
0f41a311c8 | ||
|
|
412a9a050e | ||
|
|
d5efc3abd5 | ||
|
|
a205623d52 | ||
|
|
8208eecf8c | ||
|
|
f84398eb9c | ||
|
|
aca5adb673 | ||
|
|
f361d01b8b | ||
|
|
d2cef2d26e | ||
|
|
90524e53ec | ||
|
|
668d220400 | ||
|
|
9e28db0535 | ||
|
|
c5807463fd | ||
|
|
f72a9e52f5 | ||
|
|
619582bd03 | ||
|
|
bcc02d7adc | ||
|
|
a9083d5362 | ||
|
|
dd89fa0f5b | ||
|
|
88d0bd5a1d | ||
|
|
a045c2907f | ||
|
|
bcca7655f8 | ||
|
|
269ef5f824 | ||
|
|
c80a9aab71 | ||
|
|
33180a658a | ||
|
|
c5955ada1a | ||
|
|
fd7d936a0d | ||
|
|
84cd137bae | ||
|
|
3a77a638d5 | ||
|
|
599f4f01d0 | ||
|
|
bd298e92d0 | ||
|
|
fabbfd93df | ||
|
|
1ecbc44368 |
@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
## Preparation:
|
||||
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
|
||||
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
|
||||
- Do NOT attempt any workarounds.
|
||||
- Do NOT proceed with the review.
|
||||
- ALERT about the failure and WAIT for instructions.
|
||||
- This is a hard requirement - no exceptions.
|
||||
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
|
||||
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
|
||||
56
.github/workflows/ci.yaml
vendored
56
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
HA_SHORT_VERSION: "2026.5"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore uv wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: >-
|
||||
@@ -384,7 +384,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -484,7 +484,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -515,7 +515,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -643,7 +643,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -694,7 +694,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -747,7 +747,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -804,7 +804,7 @@ jobs:
|
||||
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -812,7 +812,7 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -854,7 +854,7 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -887,7 +887,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -930,7 +930,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -964,7 +964,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1080,7 +1080,7 @@ jobs:
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1115,7 +1115,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1238,7 +1238,7 @@ jobs:
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1275,7 +1275,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1392,7 +1392,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1421,7 +1421,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1455,7 +1455,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1563,7 +1563,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1591,7 +1591,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -174,6 +174,7 @@ homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dropbox.*
|
||||
homeassistant.components.droplet.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
@@ -578,6 +579,7 @@ homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
homeassistant.components.unifi_access.*
|
||||
homeassistant.components.unifiprotect.*
|
||||
homeassistant.components.upcloud.*
|
||||
homeassistant.components.update.*
|
||||
|
||||
18
CODEOWNERS
generated
18
CODEOWNERS
generated
@@ -222,8 +222,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/binary_sensor/ @home-assistant/core
|
||||
/tests/components/binary_sensor/ @home-assistant/core
|
||||
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
|
||||
/homeassistant/components/blebox/ @bbx-a @swistakm
|
||||
/tests/components/blebox/ @bbx-a @swistakm
|
||||
/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
@@ -401,6 +401,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dremel_3d_printer/ @tkdrob
|
||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/homeassistant/components/dropbox/ @bdr99
|
||||
/tests/components/dropbox/ @bdr99
|
||||
/homeassistant/components/droplet/ @sarahseidman
|
||||
/tests/components/droplet/ @sarahseidman
|
||||
/homeassistant/components/dsmr/ @Robbie1221
|
||||
@@ -739,8 +741,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015
|
||||
/tests/components/html5/ @alexyao2015
|
||||
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/tests/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/homeassistant/components/http/ @home-assistant/core
|
||||
/tests/components/http/ @home-assistant/core
|
||||
/homeassistant/components/huawei_lte/ @scop @fphammerle
|
||||
@@ -1226,12 +1228,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/onewire/ @garbled1 @epenet
|
||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||
/tests/components/onkyo/ @arturpragacz @eclair4151
|
||||
/homeassistant/components/onvif/ @hunterjm @jterrace
|
||||
/tests/components/onvif/ @hunterjm @jterrace
|
||||
/homeassistant/components/onvif/ @jterrace
|
||||
/tests/components/onvif/ @jterrace
|
||||
/homeassistant/components/open_meteo/ @frenck
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/open_router/ @joostlek @ab3lson
|
||||
/tests/components/open_router/ @joostlek @ab3lson
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "bega",
|
||||
"name": "BEGA",
|
||||
"iot_standards": ["zigbee"]
|
||||
}
|
||||
@@ -13,6 +13,9 @@ from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
@@ -25,11 +28,17 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"close_door_failed": {
|
||||
"message": "Failed to close the garage door"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"open_door_failed": {
|
||||
"message": "Failed to open the garage door"
|
||||
}
|
||||
|
||||
@@ -45,9 +45,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
try:
|
||||
await client.models.list(timeout=10.0)
|
||||
except anthropic.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -60,7 +61,7 @@ class AnthropicTaskEntity(
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
raise HomeAssistantError(
|
||||
"Last content in chat log is not an AssistantContent"
|
||||
translation_domain=DOMAIN, translation_key="response_not_found"
|
||||
)
|
||||
|
||||
text = chat_log.content[-1].content or ""
|
||||
@@ -78,7 +79,9 @@ class AnthropicTaskEntity(
|
||||
err,
|
||||
text,
|
||||
)
|
||||
raise HomeAssistantError("Error with Claude structured response") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="json_parse_error"
|
||||
) from err
|
||||
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
|
||||
@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ from anthropic.types import (
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
Container,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
@@ -61,15 +63,16 @@ from anthropic.types import (
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultContentParam,
|
||||
Content as BashCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultContentParam,
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
@@ -105,6 +108,7 @@ from .const import (
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
)
|
||||
|
||||
@@ -224,12 +228,22 @@ def _convert_content(
|
||||
},
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "code_execution":
|
||||
tool_result_block = {
|
||||
"type": "code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "bash_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
BashCodeExecutionToolResultContentParam, content.tool_result
|
||||
BashCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "text_editor_code_execution":
|
||||
@@ -237,7 +251,7 @@ def _convert_content(
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
TextEditorCodeExecutionToolResultContentParam,
|
||||
TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
@@ -368,6 +382,7 @@ def _convert_content(
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
],
|
||||
@@ -379,6 +394,7 @@ def _convert_content(
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
@@ -401,7 +417,11 @@ def _convert_content(
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
raise HomeAssistantError("Unexpected content type in chat log")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_chat_log_content",
|
||||
translation_placeholders={"type": type(content).__name__},
|
||||
)
|
||||
|
||||
return messages, container_id
|
||||
|
||||
@@ -443,7 +463,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if stream is None or not hasattr(stream, "__aiter__"):
|
||||
raise HomeAssistantError("Expected a stream of messages")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
|
||||
)
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
@@ -464,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -526,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
),
|
||||
@@ -588,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
current_tool_block["input"] |= tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=tool_args,
|
||||
tool_args=current_tool_block["input"],
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
)
|
||||
]
|
||||
@@ -605,7 +628,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
content_details.container = response.delta.container
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_refusal"
|
||||
)
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
@@ -664,7 +689,9 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise HomeAssistantError("First message must be a system message")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="system_message_not_found"
|
||||
)
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
@@ -725,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
]
|
||||
|
||||
if options.get(CONF_CODE_EXECUTION):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_WEB_SEARCH):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_CODE_EXECUTION):
|
||||
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
|
||||
WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
)
|
||||
else:
|
||||
web_search = WebSearchTool20260209Param(
|
||||
name="web_search",
|
||||
type="web_search_20260209",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = {
|
||||
"type": "approximate",
|
||||
@@ -754,7 +796,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
last_message = messages[-1]
|
||||
if last_message["role"] != "user":
|
||||
raise HomeAssistantError(
|
||||
"Last message must be a user message to add attachments"
|
||||
translation_domain=DOMAIN, translation_key="user_message_not_found"
|
||||
)
|
||||
if isinstance(last_message["content"], str):
|
||||
last_message["content"] = [
|
||||
@@ -859,11 +901,19 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
except anthropic.AuthenticationError as err:
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
"Authentication error with Anthropic API, reauthentication required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
@@ -883,15 +933,23 @@ async def async_prepare_files_for_prompt(
|
||||
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_path",
|
||||
translation_placeholders={"file_path": file_path.as_posix()},
|
||||
)
|
||||
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
"Only images and PDF are supported by the Anthropic API,"
|
||||
f"`{file_path}` is not an image file or PDF"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_type",
|
||||
translation_placeholders={
|
||||
"file_path": file_path.as_posix(),
|
||||
"mime_type": mime_type or "unknown",
|
||||
},
|
||||
)
|
||||
if mime_type == "image/jpg":
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
@@ -59,17 +59,14 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: |
|
||||
To give examples of how people use the integration
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: |
|
||||
To write something about what models we support.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
@@ -88,7 +85,7 @@ rules:
|
||||
comment: |
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
|
||||
@@ -161,7 +161,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
is None
|
||||
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
|
||||
):
|
||||
raise HomeAssistantError("Subentry not found")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="subentry_not_found"
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
@@ -190,4 +192,6 @@ async def async_create_fix_flow(
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unknown_issue_id"
|
||||
)
|
||||
|
||||
@@ -149,6 +149,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_authentication_error": {
|
||||
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
|
||||
},
|
||||
"api_error": {
|
||||
"message": "Anthropic API error: {message}."
|
||||
},
|
||||
"api_refusal": {
|
||||
"message": "Potential policy violation detected."
|
||||
},
|
||||
"json_parse_error": {
|
||||
"message": "Error with Claude structured response."
|
||||
},
|
||||
"response_not_found": {
|
||||
"message": "Last content in chat log is not an AssistantContent."
|
||||
},
|
||||
"subentry_not_found": {
|
||||
"message": "Subentry not found."
|
||||
},
|
||||
"system_message_not_found": {
|
||||
"message": "First message must be a system message."
|
||||
},
|
||||
"unexpected_chat_log_content": {
|
||||
"message": "Unexpected content type in chat log: {type}."
|
||||
},
|
||||
"unexpected_stream_object": {
|
||||
"message": "Expected a stream of messages."
|
||||
},
|
||||
"unknown_issue_id": {
|
||||
"message": "Unknown issue ID."
|
||||
},
|
||||
"user_message_not_found": {
|
||||
"message": "Last message must be a user message to add attachments."
|
||||
},
|
||||
"wrong_file_path": {
|
||||
"message": "`{file_path}` does not exist."
|
||||
},
|
||||
"wrong_file_type": {
|
||||
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from contextlib import AsyncExitStack
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
@@ -54,36 +54,31 @@ async def _run_client(
|
||||
client = runtime_data.client
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
def _listen(_: Any) -> None:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
async with AsyncExitStack() as stack:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
stack.push_async_callback(client.stop)
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
await stack.enter_async_context(
|
||||
coordinator.async_monitor_client()
|
||||
)
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
await client.process()
|
||||
finally:
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
pass
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||
return
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -51,7 +53,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
self.update_in_progress = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
@@ -74,24 +76,34 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
self.update_in_progress = True
|
||||
await self.state.update()
|
||||
except ConnectionFailed as err:
|
||||
raise UpdateFailed(
|
||||
f"Connection failed during update for zone {self.state.zn}"
|
||||
) from err
|
||||
finally:
|
||||
self.update_in_progress = False
|
||||
|
||||
@callback
|
||||
def async_notify_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None:
|
||||
"""Packet callback to detect changes to state."""
|
||||
if (
|
||||
not isinstance(packet, ResponsePacket)
|
||||
or packet.zn != self.state.zn
|
||||
or self.update_in_progress
|
||||
):
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_notify_connected(self) -> None:
|
||||
"""Handle client connected."""
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_notify_disconnected(self) -> None:
|
||||
"""Handle client disconnected."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_monitor_client(self) -> AsyncGenerator[None]:
|
||||
"""Monitor a client and state for changes while connected."""
|
||||
async with self.state:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
try:
|
||||
with self.client.listen(self._async_notify_packet):
|
||||
yield
|
||||
finally:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@@ -26,3 +26,8 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.client.connected
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The Arris TG2492LG component."""
|
||||
"""The Arris TG2492LG integration."""
|
||||
|
||||
@@ -142,6 +142,13 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
# Home Assistant already accepts URL-based client_ids via
|
||||
# IndieAuth without prior registration, which is compatible with
|
||||
# draft-ietf-oauth-client-id-metadata-document. This flag
|
||||
# advertises that support to encourage clients to use it. The
|
||||
# metadata document is not actually fetched as IndieAuth doesn't
|
||||
# require it.
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
||||
@@ -12,7 +12,7 @@ import hashlib
|
||||
import io
|
||||
from itertools import chain
|
||||
import json
|
||||
from pathlib import Path, PurePath, PureWindowsPath
|
||||
from pathlib import Path, PurePath
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -1957,10 +1957,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
suggested_filename: str,
|
||||
) -> WrittenBackup:
|
||||
"""Receive a backup."""
|
||||
safe_filename = PureWindowsPath(suggested_filename).name
|
||||
if not safe_filename or safe_filename == "..":
|
||||
safe_filename = "backup.tar"
|
||||
temp_file = Path(self.temp_backup_dir, safe_filename)
|
||||
temp_file = Path(self.temp_backup_dir, suggested_filename)
|
||||
|
||||
async_add_executor_job = self._hass.async_add_executor_job
|
||||
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bitcoin component."""
|
||||
"""The Bitcoin integration."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blebox",
|
||||
"name": "BleBox devices",
|
||||
"codeowners": ["@bbx-a", "@swistakm"],
|
||||
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blebox",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""The BMW Connected Drive integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
DOMAIN = "bmw_connected_drive"
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up BMW Connected Drive from a config entry."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/bmw_connected_drive",
|
||||
"custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha",
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove a config entry."""
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
# Remove any remaining disabled or ignored entries
|
||||
for _entry in hass.config_entries.async_entries(DOMAIN):
|
||||
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
|
||||
@@ -1,9 +0,0 @@
|
||||
"""The BMW Connected Drive integration config flow."""
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
|
||||
class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for BMW Connected Drive."""
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": []
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
|
||||
"title": "The BMW Connected Drive integration has been removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Rotate the access token."""
|
||||
access_tokens.append(hex(_RND.getrandbits(256))[2:])
|
||||
|
||||
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
|
||||
async_track_time_interval(
|
||||
hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
|
||||
)
|
||||
|
||||
hass.http.register_view(BrandsIntegrationView(hass))
|
||||
hass.http.register_view(BrandsHardwareView(hass))
|
||||
|
||||
@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
|
||||
return STATE_OFF
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
This sets up listeners to handle state transitions for start or end of
|
||||
the current or upcoming event.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self._alarm_unsubs is None:
|
||||
self._alarm_unsubs = []
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -760,12 +760,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
|
||||
@@ -11,7 +11,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
|
||||
@@ -12,5 +12,7 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
|
||||
|
||||
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
|
||||
|
||||
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
|
||||
|
||||
# Interval between periodic state polls to catch externally-triggered changes.
|
||||
STATE_POLL_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import STATE_POLL_INTERVAL
|
||||
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +51,15 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
|
||||
)
|
||||
self.title = title
|
||||
|
||||
# The device API couples brightness and dimming time into a
|
||||
# single command (set_brightness_and_dimming_time), so both
|
||||
# values must be tracked here for cross-entity use.
|
||||
self.last_brightness_pct: int = (
|
||||
device.state.brightness_level
|
||||
if device.state.brightness_level is not None
|
||||
else SORTED_BRIGHTNESS_LEVELS[0]
|
||||
)
|
||||
|
||||
@callback
|
||||
def _needs_poll(
|
||||
self,
|
||||
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry
|
||||
|
||||
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CasperGlowConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
hass, coordinator.device.address, connectable=True
|
||||
)
|
||||
|
||||
return {
|
||||
"service_info": async_redact_data(
|
||||
service_info.as_dict() if service_info else None,
|
||||
SERVICE_INFO_TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
"resume": {
|
||||
"default": "mdi:play"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"default": "mdi:timer-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if state.brightness_level is not None:
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
@@ -97,6 +98,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
)
|
||||
)
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
|
||||
self.coordinator.last_brightness_pct = brightness_pct
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
"requirements": ["pycasperglow==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
@@ -52,8 +52,10 @@ rules:
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No applicable device classes for binary_sensor, button, light, or select entities.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
|
||||
92
homeassistant/components/casper_glow/select.py
Normal file
92
homeassistant/components/casper_glow/select.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Casper Glow integration select platform for dimming time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DIMMING_TIME_OPTIONS
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
|
||||
"""Select entity for Casper Glow dimming time."""
|
||||
|
||||
_attr_translation_key = "dimming_time"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_options = list(DIMMING_TIME_OPTIONS)
|
||||
_attr_unit_of_measurement = UnitOfTime.MINUTES
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the dimming time select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected dimming time from the coordinator."""
|
||||
if self.coordinator.last_dimming_time_minutes is None:
|
||||
return None
|
||||
return str(self.coordinator.last_dimming_time_minutes)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known dimming time and register state update callback."""
|
||||
await super().async_added_to_hass()
|
||||
if self.coordinator.last_dimming_time_minutes is None and (
|
||||
last_state := await self.async_get_last_state()
|
||||
):
|
||||
if last_state.state in DIMMING_TIME_OPTIONS:
|
||||
self.coordinator.last_dimming_time_minutes = int(last_state.state)
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.brightness_level is not None:
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
if (
|
||||
state.configured_dimming_time_minutes is not None
|
||||
and self.coordinator.last_dimming_time_minutes is None
|
||||
):
|
||||
self.coordinator.last_dimming_time_minutes = (
|
||||
state.configured_dimming_time_minutes
|
||||
)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the dimming time."""
|
||||
await self._async_command(
|
||||
self._device.set_brightness_and_dimming_time(
|
||||
self.coordinator.last_brightness_pct, int(option)
|
||||
)
|
||||
)
|
||||
self.coordinator.last_dimming_time_minutes = int(option)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
@@ -39,6 +39,11 @@
|
||||
"resume": {
|
||||
"name": "Resume dimming"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"name": "Dimming time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -30,7 +30,6 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
client = ChessComClient(session=session)
|
||||
try:
|
||||
user = await client.get_player(user_input[CONF_USERNAME])
|
||||
await client.get_player_stats(user_input[CONF_USERNAME])
|
||||
except NotFoundError:
|
||||
errors["base"] = "player_not_found"
|
||||
except Exception:
|
||||
|
||||
@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
@@ -92,7 +91,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[ClimateEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
values = load_api_data(device, "climate")
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No climate data, device is only a humidifier/dehumidifier
|
||||
|
||||
@@ -140,7 +139,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
values = load_api_data(device, "climate")
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
|
||||
@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
DOMAIN as HUMIDIFIER_DOMAIN,
|
||||
MODE_AUTO,
|
||||
MODE_NORMAL,
|
||||
HumidifierAction,
|
||||
@@ -68,7 +67,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[ComelitHumidifierEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
values = load_api_data(device, "humidifier")
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No humidity data, device is only a climate
|
||||
|
||||
@@ -142,7 +141,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
values = load_api_data(device, "humidifier")
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
|
||||
@@ -113,9 +113,6 @@
|
||||
"humidity_while_off": {
|
||||
"message": "Cannot change humidity while off"
|
||||
},
|
||||
"invalid_clima_data": {
|
||||
"message": "Invalid 'clima' data"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to update data: {error}"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, Literal
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -30,17 +29,19 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
)
|
||||
|
||||
|
||||
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
|
||||
def load_api_data(
|
||||
device: ComelitSerialBridgeObject,
|
||||
domain: Literal["climate", "humidifier"],
|
||||
) -> list[Any]:
|
||||
"""Load data from the API."""
|
||||
# This function is called when the data is loaded from the API
|
||||
if not isinstance(device.val, list):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=domain, translation_key="invalid_clima_data"
|
||||
)
|
||||
# This function is called when the data is loaded from the API.
|
||||
# For climate and humidifier device.val is always a list.
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(device.val, list)
|
||||
# CLIMATE has a 2 item tuple:
|
||||
# - first for Clima
|
||||
# - second for Humidifier
|
||||
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
|
||||
return device.val[0] if domain == "climate" else device.val[1]
|
||||
|
||||
|
||||
async def cleanup_stale_entity(
|
||||
|
||||
@@ -44,18 +44,18 @@ class DemoRemote(RemoteEntity):
|
||||
return {"last_command_sent": self._last_command_sent}
|
||||
return None
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to a device."""
|
||||
for com in command:
|
||||
self._last_command_sent = com
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -61,12 +61,12 @@ class DemoSwitch(SwitchEntity):
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -353,10 +353,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||
# Device was de/re-connected, state might have changed
|
||||
self.async_write_ha_state()
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state."""
|
||||
self._attr_supported_features = self._supported_features()
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
|
||||
async def _device_connect(self, location: str) -> None:
|
||||
"""Connect to the device now that it's available."""
|
||||
|
||||
64
homeassistant/components/dropbox/__init__.py
Normal file
64
homeassistant/components/dropbox/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""The Dropbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .auth import DropboxConfigEntryAuth
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Set up Dropbox from a config entry."""
|
||||
try:
|
||||
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
|
||||
|
||||
auth = DropboxConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), oauth2_session
|
||||
)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
|
||||
try:
|
||||
await client.get_account_info()
|
||||
except DropboxAuthException as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (DropboxUnknownException, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = 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: DropboxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
38
homeassistant/components/dropbox/application_credentials.py
Normal file
38
homeassistant/components/dropbox/application_credentials.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Application credentials platform for the Dropbox integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
AbstractOAuth2Implementation,
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> AbstractOAuth2Implementation:
|
||||
"""Return custom auth implementation."""
|
||||
return DropboxOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_TOKEN,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
data: dict = {
|
||||
"token_access_type": "offline",
|
||||
"scope": " ".join(OAUTH2_SCOPES),
|
||||
}
|
||||
data.update(super().extra_authorize_data)
|
||||
return data
|
||||
44
homeassistant/components/dropbox/auth.py
Normal file
44
homeassistant/components/dropbox/auth.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Authentication for Dropbox."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from python_dropbox_api import Auth
|
||||
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
|
||||
|
||||
class DropboxConfigEntryAuth(Auth):
|
||||
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigEntryAuth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return cast(str, self._oauth_session.token["access_token"])
|
||||
|
||||
|
||||
class DropboxConfigFlowAuth(Auth):
|
||||
"""Provide authentication tied to a fixed token for the config flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigFlowAuth."""
|
||||
super().__init__(websession)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the fixed access token."""
|
||||
return self._token
|
||||
230
homeassistant/components/dropbox/backup.py
Normal file
230
homeassistant/components/dropbox/backup.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Backup platform for the Dropbox integration."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxFileOrFolderNotFoundException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import DropboxConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
|
||||
"""Yield a string as a single bytes chunk."""
|
||||
yield content.encode()
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except DropboxFileOrFolderNotFoundException as err:
|
||||
raise BackupNotFound(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
except DropboxAuthException as err:
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
raise BackupAgentError("Authentication error") from err
|
||||
except DropboxUnknownException as err:
|
||||
_LOGGER.error(
|
||||
"Error during %s: %s",
|
||||
func.__name__,
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
||||
raise BackupAgentError(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
**kwargs: Any,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return [DropboxBackupAgent(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.
|
||||
|
||||
:return: A function to unregister the listener.
|
||||
"""
|
||||
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]:
|
||||
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
class DropboxBackupAgent(BackupAgent):
|
||||
"""Backup agent for the Dropbox integration."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
|
||||
"""Initialize the backup agent."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
self.name = entry.title
|
||||
assert entry.unique_id
|
||||
self.unique_id = entry.unique_id
|
||||
self._api: DropboxAPIClient = entry.runtime_data
|
||||
|
||||
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
|
||||
"""Get backups and their corresponding file names."""
|
||||
files = await self._api.list_folder("")
|
||||
|
||||
tar_files = {f.name for f in files if f.name.endswith(".tar")}
|
||||
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
|
||||
|
||||
backups: list[tuple[AgentBackup, str]] = []
|
||||
for metadata_file in metadata_files:
|
||||
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
|
||||
if tar_name not in tar_files:
|
||||
_LOGGER.warning(
|
||||
"Found metadata file '%s' without matching backup file",
|
||||
metadata_file.name,
|
||||
)
|
||||
continue
|
||||
|
||||
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
|
||||
raw = b"".join([chunk async for chunk in metadata_stream])
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
backup = AgentBackup.from_dict(data)
|
||||
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping invalid metadata file '%s': %s",
|
||||
metadata_file.name,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backups.append((backup, tar_name))
|
||||
|
||||
return backups
|
||||
|
||||
@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."""
|
||||
backup_filename, metadata_filename = _suggested_filenames(backup)
|
||||
backup_path = f"/{backup_filename}"
|
||||
metadata_path = f"/{metadata_filename}"
|
||||
|
||||
file_stream = await open_stream()
|
||||
await self._api.upload_file(backup_path, file_stream)
|
||||
|
||||
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
|
||||
|
||||
try:
|
||||
await self._api.upload_file(metadata_path, metadata_stream)
|
||||
except (
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
):
|
||||
await self._api.delete_file(backup_path)
|
||||
raise
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
return [backup for backup, _ in await self._async_get_backups()]
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return self._api.download_file(f"/{filename}")
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_get_backups()
|
||||
|
||||
for backup, _ in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, tar_filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
|
||||
await self._api.delete_file(f"/{tar_filename}")
|
||||
await self._api.delete_file(f"/{metadata_filename}")
|
||||
return
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
60
homeassistant/components/dropbox/config_flow.py
Normal file
60
homeassistant/components/dropbox/config_flow.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Config flow for Dropbox."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from python_dropbox_api import DropboxAPIClient
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .auth import DropboxConfigFlowAuth
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle Dropbox OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
|
||||
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
account_info = await client.get_account_info()
|
||||
|
||||
await self.async_set_unique_id(account_info.account_id)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=account_info.email, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
19
homeassistant/components/dropbox/const.py
Normal file
19
homeassistant/components/dropbox/const.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Constants for the Dropbox integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "dropbox"
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
|
||||
OAUTH2_SCOPES = [
|
||||
"account_info.read",
|
||||
"files.content.read",
|
||||
"files.content.write",
|
||||
]
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
13
homeassistant/components/dropbox/manifest.json
Normal file
13
homeassistant/components/dropbox/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "dropbox",
|
||||
"name": "Dropbox",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@bdr99"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dropbox",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-dropbox-api==0.1.3"]
|
||||
}
|
||||
112
homeassistant/components/dropbox/quality_scale.yaml
Normal file
112
homeassistant/components/dropbox/quality_scale.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration does not poll.
|
||||
brands: done
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities or coordinators.
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: Integration does not make any entity updates.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: Integration does not have any data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: Integration does not update any data.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: Integration only provides backup functionality.
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: Integration does not support any devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration does not use any devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not have any repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
35
homeassistant/components/dropbox/strings.json
Normal file
35
homeassistant/components/dropbox/strings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with the correct account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Dropbox integration needs to re-authenticate your account.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,37 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
from infrared_protocols import Timing as InfraredTiming
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEmitterEntity,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
|
||||
"""ESPHome infrared entity using native API."""
|
||||
class EsphomeInfraredEmitterEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
@@ -50,10 +60,118 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
)
|
||||
class EsphomeInfraredReceiverEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredReceiverEntity
|
||||
):
|
||||
"""ESPHome infrared receiver entity using native API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_data: RuntimeEntryData,
|
||||
entity_info: InfraredInfo,
|
||||
state_type: type[EntityState],
|
||||
) -> None:
|
||||
"""Initialize the receiver entity."""
|
||||
InfraredReceiverEntity.__init__(self)
|
||||
EsphomeEntity.__init__(self, entry_data, entity_info, state_type)
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Update static info and ensure unique_id has receiver suffix."""
|
||||
super()._on_static_info_update(static_info)
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-rx"
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks including IR receive subscription."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._client.subscribe_infrared_rf_receive(
|
||||
self._on_infrared_rf_receive,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
|
||||
"""Handle a received IR signal from the device."""
|
||||
if (
|
||||
event.key != self._static_info.key
|
||||
or event.device_id != self._static_info.device_id
|
||||
):
|
||||
return
|
||||
|
||||
timings = [
|
||||
InfraredTiming(high_us=event.timings[i], low_us=abs(event.timings[i + 1]))
|
||||
for i in range(0, len(event.timings) - 1, 2)
|
||||
]
|
||||
signal = InfraredReceivedSignal(timings=timings)
|
||||
self._handle_received_signal(signal)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up ESPHome infrared entities."""
|
||||
entry_data = entry.runtime_data
|
||||
|
||||
# Set up emitter entities via the standard platform setup
|
||||
await platform_async_setup_entry(
|
||||
hass,
|
||||
entry,
|
||||
async_add_entities,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEmitterEntity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities & InfraredCapability.TRANSMITTER
|
||||
),
|
||||
)
|
||||
|
||||
# Set up receiver entities via a second registration
|
||||
# We need a separate info tracking dict for receivers since
|
||||
# platform_async_setup_entry overwrites entry_data.info[InfraredInfo]
|
||||
receiver_entities: dict[tuple[int, int], EsphomeInfraredReceiverEntity] = {}
|
||||
|
||||
@callback
|
||||
def _on_receiver_info_update(infos: list[EntityInfo]) -> None:
|
||||
"""Handle receiver static info updates."""
|
||||
receiver_infos = [
|
||||
info
|
||||
for info in infos
|
||||
if isinstance(info, InfraredInfo)
|
||||
and info.capabilities & InfraredCapability.RECEIVER
|
||||
]
|
||||
|
||||
new_entities: list[EsphomeInfraredReceiverEntity] = []
|
||||
new_keys: set[tuple[int, int]] = set()
|
||||
|
||||
for info in receiver_infos:
|
||||
info_key = (info.device_id, info.key)
|
||||
new_keys.add(info_key)
|
||||
if info_key not in receiver_entities:
|
||||
entity = EsphomeInfraredReceiverEntity(entry_data, info, EntityState)
|
||||
receiver_entities[info_key] = entity
|
||||
new_entities.append(entity)
|
||||
|
||||
# Remove entities that are no longer present
|
||||
for info_key in list(receiver_entities):
|
||||
if info_key not in new_keys:
|
||||
del receiver_entities[info_key]
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry_data.cleanup_callbacks.append(
|
||||
entry_data.async_register_static_info_callback(
|
||||
InfraredInfo,
|
||||
_on_receiver_info_update,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fail2ban component."""
|
||||
"""The Fail2Ban integration."""
|
||||
|
||||
@@ -4,9 +4,12 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import FitbitScope
|
||||
from .const import DOMAIN, FitbitScope
|
||||
from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
@@ -16,11 +19,17 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
|
||||
"""Set up fitbit from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
fitbit_api = api.OAuthFitbitApi(
|
||||
hass, session, unit_system=entry.data.get("unit_system")
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"name": "Water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Fortinet FortiOS components."""
|
||||
"""Fortinet FortiOS integration."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support to use FortiOS device like FortiGate as device tracker.
|
||||
|
||||
This component is part of the device_tracker platform.
|
||||
This FortiOS integration provides a device_tracker platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .coordinator import (
|
||||
FreshrConfigEntry,
|
||||
@@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
readings: dict[str, FreshrReadingsCoordinator] = {
|
||||
device.id: FreshrReadingsCoordinator(
|
||||
device_id: FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
for device in devices_coordinator.data
|
||||
for device_id, device in devices_coordinator.data.items()
|
||||
}
|
||||
await asyncio.gather(
|
||||
*(
|
||||
@@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
|
||||
readings=readings,
|
||||
)
|
||||
|
||||
known_devices: set[str] = set(readings)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update() -> None:
|
||||
current = set(devices_coordinator.data)
|
||||
removed_ids = known_devices - current
|
||||
if removed_ids:
|
||||
known_devices.difference_update(removed_ids)
|
||||
for device_id in removed_ids:
|
||||
entry.runtime_data.readings.pop(device_id, None)
|
||||
new_ids = current - known_devices
|
||||
if not new_ids:
|
||||
return
|
||||
known_devices.update(new_ids)
|
||||
for device_id in new_ids:
|
||||
device = devices_coordinator.data[device_id]
|
||||
readings_coordinator = FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
entry.runtime_data.readings[device_id] = readings_coordinator
|
||||
hass.async_create_task(
|
||||
readings_coordinator.async_refresh(),
|
||||
name=f"freshr_readings_refresh_{device_id}",
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
devices_coordinator.async_add_listener(_handle_coordinator_update)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -32,7 +33,7 @@ class FreshrData:
|
||||
type FreshrConfigEntry = ConfigEntry[FreshrData]
|
||||
|
||||
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
|
||||
"""Coordinator that refreshes the device list once an hour."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
@@ -48,7 +49,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
)
|
||||
self.client = FreshrClient(session=async_create_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> list[DeviceSummary]:
|
||||
async def _async_update_data(self) -> dict[str, DeviceSummary]:
|
||||
"""Fetch the list of devices from the Fresh-r API."""
|
||||
username = self.config_entry.data[CONF_USERNAME]
|
||||
password = self.config_entry.data[CONF_PASSWORD]
|
||||
@@ -68,8 +69,23 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
else:
|
||||
return devices
|
||||
|
||||
current = {device.id: device for device in devices}
|
||||
|
||||
if self.data is not None:
|
||||
stale_ids = set(self.data) - set(current)
|
||||
if stale_ids:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in stale_ids:
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
return current
|
||||
|
||||
|
||||
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
|
||||
|
||||
34
homeassistant/components/freshr/diagnostics.py
Normal file
34
homeassistant/components/freshr/diagnostics.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Diagnostics support for Fresh-r."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FreshrConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: FreshrConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"devices": [
|
||||
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
|
||||
],
|
||||
"readings": {
|
||||
device_id: dataclasses.asdict(coordinator.data)
|
||||
if coordinator.data is not None
|
||||
else None
|
||||
for device_id, coordinator in runtime_data.readings.items()
|
||||
},
|
||||
}
|
||||
@@ -41,11 +41,13 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
discovery: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms).
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
@@ -53,7 +55,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -64,7 +66,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -112,26 +112,43 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fresh-r sensors from a config entry."""
|
||||
entities: list[FreshrSensor] = []
|
||||
for device in config_entry.runtime_data.devices.data:
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device.id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device.id],
|
||||
description,
|
||||
device_info,
|
||||
coordinator = config_entry.runtime_data.devices
|
||||
known_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _check_devices() -> None:
|
||||
current = set(coordinator.data)
|
||||
removed_ids = known_devices - current
|
||||
if removed_ids:
|
||||
known_devices.difference_update(removed_ids)
|
||||
new_ids = current - known_devices
|
||||
if not new_ids:
|
||||
return
|
||||
known_devices.update(new_ids)
|
||||
entities: list[FreshrSensor] = []
|
||||
for device_id in new_ids:
|
||||
device = coordinator.data[device_id]
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device_id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device_id],
|
||||
description,
|
||||
device_info,
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_devices()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_devices))
|
||||
|
||||
|
||||
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
|
||||
|
||||
@@ -34,23 +34,17 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: add the known supported devices
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -97,7 +97,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
super().__init__(coordinator, ain)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the HASS state machine."""
|
||||
if self.data.holiday_active:
|
||||
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
||||
@@ -109,7 +109,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
self._attr_supported_features = SUPPORTED_FEATURES
|
||||
self._attr_hvac_modes = HVAC_MODES
|
||||
self._attr_preset_modes = PRESET_MODES
|
||||
return super().async_write_ha_state()
|
||||
return super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.5"]
|
||||
"requirements": ["home-assistant-frontend==20260325.4"]
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
"requirements": ["gardena-bluetooth==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["goodwe"],
|
||||
"requirements": ["goodwe==0.4.8"]
|
||||
"requirements": ["goodwe==0.4.10"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
|
||||
from .api import ApiAuthImpl, get_feature_access
|
||||
@@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
|
||||
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
|
||||
return False
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
# Force a token refresh to fix a bug where tokens were persisted with
|
||||
# expires_in (relative time delta) and expires_at (absolute time) swapped.
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, discovery, intent
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -58,13 +63,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
except (OAuth2TokenRequestError, ClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
mem_storage = InMemoryStorage(hass)
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -26,7 +25,11 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
@@ -79,9 +82,8 @@ async def async_send_text_commands(
|
||||
session = entry.runtime_data.session
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
entry.async_start_reauth(hass)
|
||||
except OAuth2TokenRequestReauthError:
|
||||
entry.async_start_reauth(hass)
|
||||
raise
|
||||
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
|
||||
@@ -33,11 +33,18 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GooglePhotosConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Photos from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
web_session = async_get_clientsession(hass)
|
||||
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
auth = api.AsyncConfigEntryAuth(web_session, oauth_session)
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"upload_error": {
|
||||
"message": "Failed to upload content: {message}"
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
@@ -41,35 +39,23 @@ from homeassistant.components.http import (
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_NAME,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
HASSIO_USER_NAME,
|
||||
SERVER_PORT,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
issue_registry as ir,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
# config_flow, diagnostics, system_health, and entity platforms are imported to
|
||||
# ensure other dependencies that wait for hassio are not waiting
|
||||
@@ -92,19 +78,7 @@ from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
@@ -118,7 +92,6 @@ from .const import (
|
||||
DATA_SUPERVISOR_INFO,
|
||||
DOMAIN,
|
||||
HASSIO_UPDATE_INTERVAL,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
@@ -136,15 +109,11 @@ from .coordinator import (
|
||||
get_supervisor_stats,
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import (
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
get_supervisor_client,
|
||||
)
|
||||
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
|
||||
from .http import HassIOView
|
||||
from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .services import async_setup_services
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
@@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
DEPRECATION_URL = (
|
||||
"https://www.home-assistant.io/blog/2025/05/22/"
|
||||
@@ -214,148 +166,11 @@ DEPRECATION_URL = (
|
||||
)
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_32_bit() -> bool:
|
||||
size = struct.calcsize("P")
|
||||
return size * 8 == 32
|
||||
|
||||
|
||||
class APIEndpointSettings(NamedTuple):
|
||||
"""Settings for API endpoint."""
|
||||
|
||||
command: str
|
||||
schema: vol.Schema
|
||||
timeout: int | None = 60
|
||||
pass_data: bool = False
|
||||
|
||||
|
||||
MAP_SERVICE_API = {
|
||||
# Legacy addon services
|
||||
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STDIN: APIEndpointSettings(
|
||||
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
|
||||
),
|
||||
# New app services
|
||||
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
|
||||
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
|
||||
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
|
||||
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
|
||||
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
|
||||
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
|
||||
SERVICE_BACKUP_FULL: APIEndpointSettings(
|
||||
"/backups/new/full",
|
||||
SCHEMA_BACKUP_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
|
||||
"/backups/new/partial",
|
||||
SCHEMA_BACKUP_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_FULL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/full",
|
||||
SCHEMA_RESTORE_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/partial",
|
||||
SCHEMA_RESTORE_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
}
|
||||
|
||||
HARDWARE_INTEGRATIONS = {
|
||||
"green": "homeassistant_green",
|
||||
"odroid-c2": "hardkernel",
|
||||
@@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
host = os.environ["SUPERVISOR"]
|
||||
websession = async_get_clientsession(hass)
|
||||
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
|
||||
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
|
||||
supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
@@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
|
||||
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
|
||||
|
||||
async def async_service_handler(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
api_endpoint = MAP_SERVICE_API[service.service]
|
||||
|
||||
data = service.data.copy()
|
||||
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
|
||||
slug = data.pop(ATTR_SLUG, None)
|
||||
|
||||
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
|
||||
data[ATTR_ADDONS] = addons
|
||||
|
||||
payload = None
|
||||
|
||||
# Pass data to Hass.io API
|
||||
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
|
||||
payload = data[ATTR_INPUT]
|
||||
elif api_endpoint.pass_data:
|
||||
payload = data
|
||||
|
||||
# Call API
|
||||
# The exceptions are logged properly in hassio.send_command
|
||||
with suppress(HassioAPIError):
|
||||
await hassio.send_command(
|
||||
api_endpoint.command.format(addon=addon, slug=slug),
|
||||
payload=payload,
|
||||
timeout=api_endpoint.timeout,
|
||||
)
|
||||
|
||||
for service, settings in MAP_SERVICE_API.items():
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_service_handler, schema=settings.schema
|
||||
)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
# Register services
|
||||
async_setup_services(hass, supervisor_client)
|
||||
|
||||
async def update_info_data(_: datetime | None = None) -> None:
|
||||
"""Update last available supervisor information."""
|
||||
|
||||
@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
|
||||
type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
@@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
|
||||
def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
error_message: str,
|
||||
*,
|
||||
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
|
||||
) -> Callable[
|
||||
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
|
||||
]:
|
||||
"""Handle HassioAPIError and raise a specific AddonError."""
|
||||
error_type = expected_error_type or (HassioAPIError, SupervisorError)
|
||||
"""Handle SupervisorError and raise a specific AddonError."""
|
||||
|
||||
def handle_hassio_api_error(
|
||||
def handle_supervisor_error(
|
||||
func: _FuncType[_AddonManagerT, _P, _R],
|
||||
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
|
||||
"""Handle a HassioAPIError."""
|
||||
"""Handle a SupervisorError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
@@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
"""Wrap an add-on manager method."""
|
||||
try:
|
||||
return_value = await func(self, *args, **kwargs)
|
||||
except error_type as err:
|
||||
except SupervisorError as err:
|
||||
raise AddonError(
|
||||
f"{error_message.format(addon_name=self.addon_name)}: {err}"
|
||||
) from err
|
||||
@@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
|
||||
return wrapper
|
||||
|
||||
return handle_hassio_api_error
|
||||
return handle_supervisor_error
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,10 +125,7 @@ class AddonManager:
|
||||
)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app discovery info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app discovery info")
|
||||
async def async_get_addon_discovery_info(self) -> dict:
|
||||
"""Return add-on discovery info."""
|
||||
discovery_info = next(
|
||||
@@ -148,10 +142,7 @@ class AddonManager:
|
||||
|
||||
return discovery_info.config
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app info")
|
||||
async def async_get_addon_info(self) -> AddonInfo:
|
||||
"""Return and cache manager add-on info."""
|
||||
addon_store_info = await self._supervisor_client.store.addon_info(
|
||||
@@ -199,19 +190,14 @@ class AddonManager:
|
||||
version=addon_info.version,
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to set the {addon_name} app options")
|
||||
async def async_set_addon_options(self, config: dict) -> None:
|
||||
"""Set manager add-on options."""
|
||||
await self._supervisor_client.addons.set_addon_options(
|
||||
self.addon_slug, AddonsOptions(config=config)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to install the {addon_name} app")
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
try:
|
||||
@@ -221,10 +207,7 @@ class AddonManager:
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to uninstall the {addon_name} app")
|
||||
async def async_uninstall_addon(self) -> None:
|
||||
"""Uninstall the managed add-on."""
|
||||
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
|
||||
@@ -259,31 +242,22 @@ class AddonManager:
|
||||
self.addon_slug, StoreAddonUpdate(backup=False)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to start the {addon_name} app")
|
||||
async def async_start_addon(self) -> None:
|
||||
"""Start the managed add-on."""
|
||||
await self._supervisor_client.addons.start_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to restart the {addon_name} app")
|
||||
async def async_restart_addon(self) -> None:
|
||||
"""Restart the managed add-on."""
|
||||
await self._supervisor_client.addons.restart_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to stop the {addon_name} app")
|
||||
async def async_stop_addon(self) -> None:
|
||||
"""Stop the managed add-on."""
|
||||
await self._supervisor_client.addons.stop_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to create a backup of the {addon_name} app")
|
||||
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
|
||||
"""Create a partial backup of the managed add-on."""
|
||||
if addon_info:
|
||||
|
||||
439
homeassistant/components/hassio/services.py
Normal file
439
homeassistant/components/hassio/services.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""Set up Supervisor services."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
PartialRestoreOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register the Supervisor services."""
|
||||
async_register_app_services(hass, supervisor_client)
|
||||
async_register_host_services(hass, supervisor_client)
|
||||
async_register_backup_restore_services(hass, supervisor_client)
|
||||
async_register_network_storage_services(hass, supervisor_client)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_app_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register app services."""
|
||||
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_app_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app services which only take a slug and have no response."""
|
||||
action, api_method = simple_app_services[service.service]
|
||||
app_slug = service.data[ATTR_APP]
|
||||
|
||||
try:
|
||||
await api_method(app_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_app_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
|
||||
)
|
||||
|
||||
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app stdin service."""
|
||||
app_slug = service.data[ATTR_APP]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# For backwards compatibility the payload here must be valid json
|
||||
# This is sensible when a dictionary is provided, it must be serialized
|
||||
# If user provides a string though, we wrap it in quotes before encoding
|
||||
# This is purely for legacy reasons, Supervisor has no json requirement
|
||||
# Supervisor just hands the raw request as binary to the container
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APP_STDIN,
|
||||
async_app_stdin_service_handler,
|
||||
schema=SCHEMA_APP_STDIN,
|
||||
)
|
||||
|
||||
# LEGACY - Register equivalent addon services for compatibility
|
||||
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon services which only take a slug and have no response."""
|
||||
action, api_method = simple_addon_services[service.service]
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
|
||||
try:
|
||||
await api_method(addon_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_addon_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
|
||||
)
|
||||
|
||||
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon stdin service."""
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# See explanation for why we make strings into json in async_app_stdin_service_handler
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADDON_STDIN,
|
||||
async_addon_stdin_service_handler,
|
||||
schema=SCHEMA_ADDON_STDIN,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_host_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register host services."""
|
||||
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
|
||||
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
|
||||
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
|
||||
}
|
||||
|
||||
async def async_simple_host_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for host services that take no input and return no response."""
|
||||
action, api_method = simple_host_services[service.service]
|
||||
try:
|
||||
await api_method()
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
|
||||
|
||||
for service in simple_host_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_restore_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register backup and restore services."""
|
||||
|
||||
async def async_full_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create full backup service. Returns the new backup's ID."""
|
||||
options = FullBackupOptions(**service.data)
|
||||
try:
|
||||
backup = await supervisor_client.backups.full_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create full backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_FULL,
|
||||
async_full_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_FULL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_partial_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
backup = await supervisor_client.backups.partial_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create partial backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_PARTIAL,
|
||||
async_partial_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_PARTIAL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_full_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for full restore service."""
|
||||
backup_slug = service.data[ATTR_SLUG]
|
||||
options: FullRestoreOptions | None = None
|
||||
if ATTR_PASSWORD in service.data:
|
||||
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.full_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to full restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_FULL,
|
||||
async_full_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_FULL,
|
||||
)
|
||||
|
||||
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.partial_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to partial restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_PARTIAL,
|
||||
async_partial_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_PARTIAL,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_network_storage_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register network storage (or mount) services."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
@@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
from . import HassioAPIError
|
||||
from .config import HassioUpdateParametersDict
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
@@ -40,6 +39,7 @@ from .const import (
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_addons_list
|
||||
from .handler import HassioAPIError
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.functionalChannels import MultiModeInputChannel
|
||||
from homematicip.device import (
|
||||
AccelerationSensor,
|
||||
@@ -74,6 +74,30 @@ SAM_DEVICE_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_full_flush_lock_controller(device: object) -> bool:
|
||||
"""Return whether the device is an HmIP-FLC."""
|
||||
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
|
||||
device, "functionalChannels"
|
||||
)
|
||||
|
||||
|
||||
def _get_channel_by_role(
|
||||
device: object,
|
||||
functional_channel_type: str,
|
||||
channel_role: str,
|
||||
) -> object | None:
|
||||
"""Return the matching functional channel for the device."""
|
||||
for channel in getattr(device, "functionalChannels", []):
|
||||
channel_type = getattr(channel, "functionalChannelType", None)
|
||||
channel_type_name = getattr(channel_type, "name", channel_type)
|
||||
if channel_type_name != functional_channel_type:
|
||||
continue
|
||||
if getattr(channel, "channelRole", None) != channel_role:
|
||||
continue
|
||||
return channel
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
@@ -122,6 +146,9 @@ async def async_setup_entry(
|
||||
entities.append(
|
||||
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
|
||||
)
|
||||
if _is_full_flush_lock_controller(device):
|
||||
entities.append(HomematicipFullFlushLockControllerLocked(hap, device))
|
||||
entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device))
|
||||
if isinstance(device, PresenceDetectorIndoor):
|
||||
entities.append(HomematicipPresenceDetector(hap, device))
|
||||
if isinstance(device, SmokeDetector):
|
||||
@@ -298,6 +325,55 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
return self._device.motionDetected
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerLocked(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""Representation of the HomematicIP full flush lock controller lock state."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.LOCK
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the full flush lock controller lock sensor."""
|
||||
super().__init__(hap, device, post="Locked")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the controlled lock is locked."""
|
||||
channel = _get_channel_by_role(
|
||||
self._device,
|
||||
"MULTI_MODE_LOCK_INPUT_CHANNEL",
|
||||
"DOOR_LOCK_SENSOR",
|
||||
)
|
||||
if channel is None:
|
||||
return False
|
||||
lock_state = getattr(channel, "lockState", None)
|
||||
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerGlassBreak(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""Representation of the HomematicIP full flush lock controller glass state."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the full flush lock controller glass break sensor."""
|
||||
super().__init__(hap, device, post="Glass break")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if glass break has been detected."""
|
||||
channel = _get_channel_by_role(
|
||||
self._device,
|
||||
"MULTI_MODE_LOCK_INPUT_CHANNEL",
|
||||
"DOOR_LOCK_SENSOR",
|
||||
)
|
||||
if channel is None:
|
||||
return False
|
||||
return bool(getattr(channel, "glassBroken", False))
|
||||
|
||||
|
||||
class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP presence detector."""
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
|
||||
def _is_full_flush_lock_controller(device: object) -> bool:
|
||||
"""Return whether the device is an HmIP-FLC."""
|
||||
return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr(
|
||||
device, "send_start_impulse_async"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
@@ -20,11 +27,17 @@ async def async_setup_entry(
|
||||
"""Set up the HomematicIP button from a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
entities: list[ButtonEntity] = [
|
||||
HomematicipGarageDoorControllerButton(hap, device)
|
||||
for device in hap.home.devices
|
||||
if isinstance(device, WallMountedGarageDoorController)
|
||||
]
|
||||
entities.extend(
|
||||
HomematicipFullFlushLockControllerButton(hap, device)
|
||||
for device in hap.home.devices
|
||||
if _is_full_flush_lock_controller(device)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity):
|
||||
@@ -38,3 +51,16 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._device.send_start_impulse_async()
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity):
|
||||
"""Representation of the HomematicIP full flush lock controller opener."""
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the full flush lock controller opener button."""
|
||||
super().__init__(hap, device, post="Door opener")
|
||||
self._attr_icon = "mdi:door-open"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._device.send_start_impulse_async()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["python_qube_heatpump"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-qube-heatpump==1.7.0"]
|
||||
"requirements": ["python-qube-heatpump==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY]
|
||||
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HTML5 services."""
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -7,3 +7,22 @@ SERVICE_DISMISS = "dismiss"
|
||||
ATTR_VAPID_PUB_KEY = "vapid_pub_key"
|
||||
ATTR_VAPID_PRV_KEY = "vapid_prv_key"
|
||||
ATTR_VAPID_EMAIL = "vapid_email"
|
||||
|
||||
REGISTRATIONS_FILE = "html5_push_registrations.conf"
|
||||
|
||||
ATTR_ACTION = "action"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_BADGE = "badge"
|
||||
ATTR_DATA = "data"
|
||||
ATTR_DIR = "dir"
|
||||
ATTR_ICON = "icon"
|
||||
ATTR_IMAGE = "image"
|
||||
ATTR_LANG = "lang"
|
||||
ATTR_RENOTIFY = "renotify"
|
||||
ATTR_REQUIRE_INTERACTION = "require_interaction"
|
||||
ATTR_SILENT = "silent"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_TTL = "ttl"
|
||||
ATTR_URGENCY = "urgency"
|
||||
ATTR_VIBRATE = "vibrate"
|
||||
|
||||
73
homeassistant/components/html5/entity.py
Normal file
73
homeassistant/components/html5/entity.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Base entities for HTML5 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class Keys(TypedDict):
|
||||
"""Types for keys."""
|
||||
|
||||
p256dh: str
|
||||
auth: str
|
||||
|
||||
|
||||
class Subscription(TypedDict):
|
||||
"""Types for subscription."""
|
||||
|
||||
endpoint: str
|
||||
expirationTime: int | None
|
||||
keys: Keys
|
||||
|
||||
|
||||
class Registration(TypedDict):
|
||||
"""Types for registration."""
|
||||
|
||||
subscription: Subscription
|
||||
browser: str
|
||||
name: NotRequired[str]
|
||||
|
||||
|
||||
class HTML5Entity(Entity):
|
||||
"""Base entity for HTML5 integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_key: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
target: str,
|
||||
registrations: dict[str, Registration],
|
||||
session: ClientSession,
|
||||
json_path: str,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.config_entry = config_entry
|
||||
self.target = target
|
||||
self.registrations = registrations
|
||||
self.registration = registrations[target]
|
||||
self.session = session
|
||||
self.json_path = json_path
|
||||
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=target,
|
||||
model=self.registration["browser"].capitalize(),
|
||||
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.target in self.registrations
|
||||
67
homeassistant/components/html5/event.py
Normal file
67
homeassistant/components/html5/event.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Event platform for HTML5 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.event import EventEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE
|
||||
from .entity import HTML5Entity
|
||||
from .notify import _load_config
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the event entity platform."""
|
||||
|
||||
json_path = hass.config.path(REGISTRATIONS_FILE)
|
||||
registrations = await hass.async_add_executor_job(_load_config, json_path)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
async_add_entities(
|
||||
HTML5EventEntity(config_entry, target, registrations, session, json_path)
|
||||
for target in registrations
|
||||
)
|
||||
|
||||
|
||||
class HTML5EventEntity(HTML5Entity, EventEntity):
|
||||
"""Representation of an event entity."""
|
||||
|
||||
_key = "event"
|
||||
_attr_event_types = ["clicked", "received", "closed"]
|
||||
_attr_translation_key = "event"
|
||||
|
||||
@callback
|
||||
def _async_handle_event(
|
||||
self, target: str, event_type: str, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle the event."""
|
||||
|
||||
if target == self.target:
|
||||
self._trigger_event(
|
||||
event_type,
|
||||
{
|
||||
**event_data.get(ATTR_DATA, {}),
|
||||
ATTR_ACTION: event_data.get(ATTR_ACTION),
|
||||
ATTR_TAG: event_data.get(ATTR_TAG),
|
||||
},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register event callback."""
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
|
||||
)
|
||||
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"event": {
|
||||
"default": "mdi:gesture-tap-button"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"service": "mdi:bell-off"
|
||||
},
|
||||
"send_message": {
|
||||
"service": "mdi:message-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
homeassistant/components/html5/issue.py
Normal file
31
homeassistant/components/html5/issue.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Issues for HTML5 integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def deprecated_notify_action_call(
|
||||
hass: HomeAssistant, target: list[str] | None
|
||||
) -> None:
|
||||
"""Deprecated action call."""
|
||||
|
||||
action = (
|
||||
f"notify.html5_{slugify(target[0])}"
|
||||
if target and len(target) == 1
|
||||
else "notify.html5"
|
||||
)
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_notify_action_{action}",
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_notify_action",
|
||||
translation_placeholders={"action": action},
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "html5",
|
||||
"name": "HTML5 Push Notifications",
|
||||
"codeowners": ["@alexyao2015"],
|
||||
"codeowners": ["@alexyao2015", "@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/html5",
|
||||
|
||||
@@ -8,7 +8,7 @@ from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
import uuid
|
||||
|
||||
@@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.json import save_json
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
@@ -46,17 +46,24 @@ from homeassistant.util import ensure_unique_string
|
||||
from homeassistant.util.json import load_json_object
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_VAPID_EMAIL,
|
||||
ATTR_VAPID_PRV_KEY,
|
||||
ATTR_VAPID_PUB_KEY,
|
||||
DOMAIN,
|
||||
REGISTRATIONS_FILE,
|
||||
SERVICE_DISMISS,
|
||||
)
|
||||
from .entity import HTML5Entity, Registration
|
||||
from .issue import deprecated_notify_action_call
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REGISTRATIONS_FILE = "html5_push_registrations.conf"
|
||||
|
||||
|
||||
ATTR_SUBSCRIPTION = "subscription"
|
||||
ATTR_BROWSER = "browser"
|
||||
@@ -67,15 +74,11 @@ ATTR_AUTH = "auth"
|
||||
ATTR_P256DH = "p256dh"
|
||||
ATTR_EXPIRATIONTIME = "expirationTime"
|
||||
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_ACTION = "action"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_URL = "url"
|
||||
ATTR_DISMISS = "dismiss"
|
||||
ATTR_PRIORITY = "priority"
|
||||
DEFAULT_PRIORITY = "normal"
|
||||
ATTR_TTL = "ttl"
|
||||
DEFAULT_TTL = 86400
|
||||
|
||||
DEFAULT_BADGE = "/static/images/notification-badge.png"
|
||||
@@ -156,29 +159,6 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = (
|
||||
)
|
||||
|
||||
|
||||
class Keys(TypedDict):
|
||||
"""Types for keys."""
|
||||
|
||||
p256dh: str
|
||||
auth: str
|
||||
|
||||
|
||||
class Subscription(TypedDict):
|
||||
"""Types for subscription."""
|
||||
|
||||
endpoint: str
|
||||
expirationTime: int | None
|
||||
keys: Keys
|
||||
|
||||
|
||||
class Registration(TypedDict):
|
||||
"""Types for registration."""
|
||||
|
||||
subscription: Subscription
|
||||
browser: str
|
||||
name: NotRequired[str]
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -419,7 +399,15 @@ class HTML5PushCallbackView(HomeAssistantView):
|
||||
)
|
||||
|
||||
event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}"
|
||||
request.app[KEY_HASS].bus.fire(event_name, event_payload)
|
||||
hass = request.app[KEY_HASS]
|
||||
hass.bus.fire(event_name, event_payload)
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
DOMAIN,
|
||||
event_payload[ATTR_TARGET],
|
||||
event_payload[ATTR_TYPE],
|
||||
event_payload,
|
||||
)
|
||||
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
|
||||
|
||||
|
||||
@@ -480,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
|
||||
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
|
||||
|
||||
tag = str(uuid.uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
@@ -613,65 +604,60 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class HTML5NotifyEntity(NotifyEntity):
|
||||
class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
|
||||
"""Representation of a notification entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
_attr_supported_features = NotifyEntityFeature.TITLE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
target: str,
|
||||
registrations: dict[str, Registration],
|
||||
session: ClientSession,
|
||||
json_path: str,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.config_entry = config_entry
|
||||
self.target = target
|
||||
self.registrations = registrations
|
||||
self.registration = registrations[target]
|
||||
self.session = session
|
||||
self.json_path = json_path
|
||||
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_{target}_device"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=target,
|
||||
model=self.registration["browser"].capitalize(),
|
||||
identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")},
|
||||
)
|
||||
_key = "device"
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to a device."""
|
||||
timestamp = int(time.time())
|
||||
tag = str(uuid.uuid4())
|
||||
"""Send a message to a device via notify.send_message action."""
|
||||
await self._webpush(
|
||||
title=title or ATTR_TITLE_DEFAULT,
|
||||
message=message,
|
||||
badge=DEFAULT_BADGE,
|
||||
icon=DEFAULT_ICON,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
"body": message,
|
||||
"icon": DEFAULT_ICON,
|
||||
ATTR_TAG: tag,
|
||||
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
|
||||
"timestamp": timestamp * 1000,
|
||||
ATTR_DATA: {
|
||||
ATTR_JWT: add_jwt(
|
||||
timestamp,
|
||||
self.target,
|
||||
tag,
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
},
|
||||
}
|
||||
async def send_push_notification(self, **kwargs: Any) -> None:
|
||||
"""Send a message to a device via html5.send_message action."""
|
||||
await self._webpush(**kwargs)
|
||||
self._async_record_notification()
|
||||
|
||||
async def _webpush(
|
||||
self,
|
||||
message: str | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
ttl: timedelta | None = None,
|
||||
urgency: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Shared internal helper to push messages."""
|
||||
payload: dict[str, Any] = kwargs
|
||||
|
||||
if message is not None:
|
||||
payload["body"] = message
|
||||
|
||||
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
|
||||
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
|
||||
payload[ATTR_TIMESTAMP] = ts * 1000
|
||||
|
||||
if ATTR_REQUIRE_INTERACTION in payload:
|
||||
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
|
||||
|
||||
payload.setdefault(ATTR_DATA, {})
|
||||
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
|
||||
ts,
|
||||
self.target,
|
||||
payload[ATTR_TAG],
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
|
||||
endpoint = urlparse(self.registration["subscription"]["endpoint"])
|
||||
vapid_claims = {
|
||||
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
|
||||
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
|
||||
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -680,6 +666,8 @@ class HTML5NotifyEntity(NotifyEntity):
|
||||
json.dumps(payload),
|
||||
self.config_entry.data[ATTR_VAPID_PRV_KEY],
|
||||
vapid_claims,
|
||||
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
|
||||
headers={"Urgency": urgency} if urgency else None,
|
||||
aiohttp_session=self.session,
|
||||
)
|
||||
cast(ClientResponse, response).raise_for_status()
|
||||
@@ -714,8 +702,3 @@ class HTML5NotifyEntity(NotifyEntity):
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={"target": self.target},
|
||||
) from e
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.target in self.registrations
|
||||
|
||||
82
homeassistant/components/html5/services.py
Normal file
82
homeassistant/components/html5/services.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Service registration for HTML5 integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
ATTR_TITLE_DEFAULT,
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_BADGE,
|
||||
ATTR_DIR,
|
||||
ATTR_ICON,
|
||||
ATTR_IMAGE,
|
||||
ATTR_LANG,
|
||||
ATTR_RENOTIFY,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_SILENT,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_URGENCY,
|
||||
ATTR_VIBRATE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
SERVICE_SEND_MESSAGE = "send_message"
|
||||
|
||||
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
|
||||
vol.Optional(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
vol.Optional(ATTR_BADGE): cv.string,
|
||||
vol.Optional(ATTR_IMAGE): cv.string,
|
||||
vol.Optional(ATTR_TAG): cv.string,
|
||||
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=0))],
|
||||
),
|
||||
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
|
||||
vol.Optional(ATTR_LANG): cv.language,
|
||||
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
|
||||
vol.Optional(ATTR_RENOTIFY): cv.boolean,
|
||||
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
|
||||
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
|
||||
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
{
|
||||
vol.Required(ATTR_ACTION): cv.string,
|
||||
vol.Required(ATTR_TITLE): cv.string,
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
}
|
||||
],
|
||||
),
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for HTML5 integration."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
entity_domain=NOTIFY_DOMAIN,
|
||||
schema=SERVICE_SEND_MESSAGE_SCHEMA,
|
||||
func="send_push_notification",
|
||||
)
|
||||
@@ -8,3 +8,137 @@ dismiss:
|
||||
example: '{ "tag": "tagname" }'
|
||||
selector:
|
||||
object:
|
||||
send_message:
|
||||
target:
|
||||
entity:
|
||||
domain: notify
|
||||
integration: html5
|
||||
fields:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
example: Home Assistant
|
||||
default: Home Assistant
|
||||
message:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
example: Hello World
|
||||
icon:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/icons/favicon-192x192.png
|
||||
badge:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/notification-badge.png
|
||||
image:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/image.jpg
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
example: message-group-1
|
||||
actions:
|
||||
selector:
|
||||
object:
|
||||
label_field: "action"
|
||||
description_field: "title"
|
||||
multiple: true
|
||||
translation_key: actions
|
||||
fields:
|
||||
action:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
|
||||
dir:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- auto
|
||||
- ltr
|
||||
- rtl
|
||||
mode: dropdown
|
||||
translation_key: dir
|
||||
example: auto
|
||||
renotify:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
silent:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
require_interaction:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
vibrate:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
type: number
|
||||
suffix: ms
|
||||
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
|
||||
lang:
|
||||
required: false
|
||||
selector:
|
||||
language:
|
||||
example: es-419
|
||||
timestamp:
|
||||
required: false
|
||||
selector:
|
||||
datetime:
|
||||
example: "1970-01-01 00:00:00"
|
||||
ttl:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
example: "{'days': 28}"
|
||||
urgency:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- low
|
||||
- normal
|
||||
- high
|
||||
mode: dropdown
|
||||
translation_key: urgency
|
||||
example: normal
|
||||
data:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
example: "{'customKey': 'customValue'}"
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"event": {
|
||||
"state_attributes": {
|
||||
"action": { "name": "Action" },
|
||||
"event_type": {
|
||||
"state": {
|
||||
"clicked": "Clicked",
|
||||
"closed": "Closed",
|
||||
"received": "Received"
|
||||
}
|
||||
},
|
||||
"tag": { "name": "Tag" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"channel_expired": {
|
||||
"message": "Notification channel for {target} has expired"
|
||||
@@ -32,9 +49,41 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue": {
|
||||
"description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually.",
|
||||
"title": "HTML5 YAML configuration import failed"
|
||||
"deprecated_notify_action": {
|
||||
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.",
|
||||
"title": "Detected use of deprecated action {action}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"actions": {
|
||||
"fields": {
|
||||
"action": {
|
||||
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
|
||||
"name": "Action identifier"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL of an image displayed as the icon for this button.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"title": {
|
||||
"description": "The label of the button displayed to the user.",
|
||||
"name": "Title"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dir": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"ltr": "Left-to-right",
|
||||
"rtl": "Right-to-left"
|
||||
}
|
||||
},
|
||||
"urgency": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -51,6 +100,80 @@
|
||||
}
|
||||
},
|
||||
"name": "Dismiss"
|
||||
},
|
||||
"send_message": {
|
||||
"description": "Sends a message via HTML5 Push Notifications",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"badge": {
|
||||
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
|
||||
"name": "Badge"
|
||||
},
|
||||
"data": {
|
||||
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
|
||||
"name": "Extra data"
|
||||
},
|
||||
"dir": {
|
||||
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
|
||||
"name": "Text direction"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"image": {
|
||||
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
|
||||
"name": "Image"
|
||||
},
|
||||
"lang": {
|
||||
"description": "The language of the notification's content.",
|
||||
"name": "Language"
|
||||
},
|
||||
"message": {
|
||||
"description": "The message body of the notification.",
|
||||
"name": "Message"
|
||||
},
|
||||
"renotify": {
|
||||
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
|
||||
"name": "Renotify"
|
||||
},
|
||||
"require_interaction": {
|
||||
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
|
||||
"name": "Require interaction"
|
||||
},
|
||||
"silent": {
|
||||
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
|
||||
"name": "Silent"
|
||||
},
|
||||
"tag": {
|
||||
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
|
||||
"name": "Tag"
|
||||
},
|
||||
"timestamp": {
|
||||
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
|
||||
"name": "Timestamp"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title for your notification message.",
|
||||
"name": "Title"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
|
||||
"name": "Time to live"
|
||||
},
|
||||
"urgency": {
|
||||
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
|
||||
"name": "Urgency"
|
||||
},
|
||||
"vibrate": {
|
||||
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
|
||||
"name": "Vibration pattern"
|
||||
}
|
||||
},
|
||||
"name": "Send message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from huum.const import SaunaStatus
|
||||
@@ -18,12 +17,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
|
||||
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN
|
||||
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
|
||||
from .entity import HuumBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -113,5 +110,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
try:
|
||||
await self.coordinator.huum.turn_on(temperature)
|
||||
except (ValueError, SafetyException) as err:
|
||||
_LOGGER.error(str(err))
|
||||
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_turn_on",
|
||||
) from err
|
||||
|
||||
@@ -56,5 +56,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
|
||||
return await self.huum.status()
|
||||
except (Forbidden, NotAuthenticated) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Could not log in to Huum with given credentials"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user