mirror of
https://github.com/home-assistant/core.git
synced 2026-05-09 17:59:40 +00:00
Compare commits
454 Commits
get_automa
...
smarla-int
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f732b6a7 | ||
|
|
6e74df7d8c | ||
|
|
3ad45d4a30 | ||
|
|
00ccaf1ff9 | ||
|
|
acc32eea3e | ||
|
|
f0a3ecd27b | ||
|
|
6bbd357547 | ||
|
|
f14874bc3c | ||
|
|
73cfcc285b | ||
|
|
d5e4ed2b9e | ||
|
|
8893f97f95 | ||
|
|
2bdcdaf446 | ||
|
|
73d37972df | ||
|
|
b456af473b | ||
|
|
2fa18c6838 | ||
|
|
2a96ea0418 | ||
|
|
7b2641d136 | ||
|
|
baeb70af8a | ||
|
|
00cd8f70b9 | ||
|
|
b6d51504c4 | ||
|
|
2a2cef349d | ||
|
|
7adc1e7e47 | ||
|
|
d19aabfcd0 | ||
|
|
917b467b85 | ||
|
|
a938001805 | ||
|
|
9e6de48a22 | ||
|
|
569aeff054 | ||
|
|
c86ba49a79 | ||
|
|
3b4004607d | ||
|
|
c68e663a1c | ||
|
|
d870410413 | ||
|
|
9a8c29e05d | ||
|
|
a54c8a88ff | ||
|
|
ca914d8e4f | ||
|
|
687bedd251 | ||
|
|
5ddadcbd65 | ||
|
|
981842ee87 | ||
|
|
ab69223d75 | ||
|
|
69f0f38a09 | ||
|
|
40267760fd | ||
|
|
6e74b56649 | ||
|
|
d35802a996 | ||
|
|
c007286fd6 | ||
|
|
b5a3cedacd | ||
|
|
3d53bdc6c5 | ||
|
|
a7f6a6f22c | ||
|
|
7893eaa389 | ||
|
|
e410977e64 | ||
|
|
d48ca1d858 | ||
|
|
8758a086c1 | ||
|
|
1db5c514e6 | ||
|
|
613aa9b2cf | ||
|
|
4c6e854cad | ||
|
|
66a6e55310 | ||
|
|
bffbd5607b | ||
|
|
12b5dbdd83 | ||
|
|
9e7ae1daa4 | ||
|
|
f36ee88a87 | ||
|
|
8b3bad1f54 | ||
|
|
b407792bd1 | ||
|
|
12376a2338 | ||
|
|
088cfc3576 | ||
|
|
01b8f97201 | ||
|
|
195e34cc09 | ||
|
|
fbab6741af | ||
|
|
e2b9e21c6a | ||
|
|
4a26352c50 | ||
|
|
d43371ed2f | ||
|
|
4f24d63de1 | ||
|
|
cd9339903f | ||
|
|
ca01bdc481 | ||
|
|
39a5341ab8 | ||
|
|
3c93f6e3f9 | ||
|
|
13ce4322ac | ||
|
|
b3ba506e6c | ||
|
|
ea9fc6052d | ||
|
|
c8ceea4be8 | ||
|
|
3df993b9a4 | ||
|
|
980f19173f | ||
|
|
b1da600269 | ||
|
|
34c5f79983 | ||
|
|
4cd3527761 | ||
|
|
1dbe1955eb | ||
|
|
743abadfcf | ||
|
|
bbd223af1f | ||
|
|
cb717c0ec6 | ||
|
|
4956cf3727 | ||
|
|
f76165e761 | ||
|
|
dd00d0daad | ||
|
|
77ec87d0ac | ||
|
|
2209f0b884 | ||
|
|
61fd073a5c | ||
|
|
efa7fe0dc9 | ||
|
|
00a1d9d1b0 | ||
|
|
630c438834 | ||
|
|
3ada93b293 | ||
|
|
291499d5e1 | ||
|
|
08c453581c | ||
|
|
5e25bbba2d | ||
|
|
eb85185072 | ||
|
|
3f72030d5f | ||
|
|
69a4d2107f | ||
|
|
46fe132e83 | ||
|
|
c60f19b35b | ||
|
|
ba44986524 | ||
|
|
73811eac0a | ||
|
|
3ff3cb975b | ||
|
|
8ec5472b79 | ||
|
|
b0415588d7 | ||
|
|
abcf925b79 | ||
|
|
37e13505cf | ||
|
|
5d76d92bcf | ||
|
|
b71870aba3 | ||
|
|
734d6cd247 | ||
|
|
40faa156e2 | ||
|
|
4737091722 | ||
|
|
4160ed190c | ||
|
|
8e74f63d47 | ||
|
|
fc62bc5fc1 | ||
|
|
010b4f6b15 | ||
|
|
b16d4dd94b | ||
|
|
0813adc327 | ||
|
|
e68cf80531 | ||
|
|
258c91d483 | ||
|
|
64d6101fb7 | ||
|
|
fb0cb7cad6 | ||
|
|
a3c0b83dee | ||
|
|
1ff5dd8ef5 | ||
|
|
f3f5fca0b9 | ||
|
|
d15a1a6711 | ||
|
|
e39c8e350c | ||
|
|
c1da554eb1 | ||
|
|
1f1fd8de87 | ||
|
|
cf6cb0bd39 | ||
|
|
a8264ae8ae | ||
|
|
642dc5b49c | ||
|
|
43ae0f2541 | ||
|
|
f2233b3034 | ||
|
|
c3fe5f012e | ||
|
|
7f9b454922 | ||
|
|
15915680b5 | ||
|
|
2e4226d7d3 | ||
|
|
0cd93e7e65 | ||
|
|
611d5be40a | ||
|
|
072bf75d71 | ||
|
|
f9000ae08c | ||
|
|
c8183bd35a | ||
|
|
99f91003d8 | ||
|
|
ed2024e67a | ||
|
|
cd91aca3b5 | ||
|
|
77ea654a1f | ||
|
|
ef6d3a5236 | ||
|
|
502574e86f | ||
|
|
fd1ddbd93d | ||
|
|
a12bc70543 | ||
|
|
b84e93f462 | ||
|
|
f4b0baecd3 | ||
|
|
f700a1faa3 | ||
|
|
eb90f5a581 | ||
|
|
20ce879471 | ||
|
|
df3688ef08 | ||
|
|
e76bd1bbb9 | ||
|
|
d580f8a8a2 | ||
|
|
ffb485aa87 | ||
|
|
741cb23776 | ||
|
|
6afb60d31b | ||
|
|
761bb65ac6 | ||
|
|
7464e3944e | ||
|
|
e2f2c13e5e | ||
|
|
0ee0b2fcba | ||
|
|
1e9c585e8b | ||
|
|
e78f4d2a29 | ||
|
|
1f6faaacab | ||
|
|
7e895f7d10 | ||
|
|
5031ffe767 | ||
|
|
37fe25cfdc | ||
|
|
cff7aa229e | ||
|
|
e09dde2ea9 | ||
|
|
cadbe885d1 | ||
|
|
366f592a8a | ||
|
|
e491629143 | ||
|
|
f44cb9b03e | ||
|
|
752c73a2ed | ||
|
|
a8ecdb3bff | ||
|
|
9c798cbb5d | ||
|
|
8938c109c2 | ||
|
|
85448ea903 | ||
|
|
e3d2f917e2 | ||
|
|
05795d0ad8 | ||
|
|
a38e033e13 | ||
|
|
8df447091d | ||
|
|
760f2d1959 | ||
|
|
e64f76bebe | ||
|
|
7c5090d627 | ||
|
|
f6a0d630c3 | ||
|
|
880f5faeec | ||
|
|
0cf503d871 | ||
|
|
9d050360c8 | ||
|
|
0c0c61f9e0 | ||
|
|
e868b3e8ff | ||
|
|
555215a848 | ||
|
|
484a547758 | ||
|
|
7d25f68fa5 | ||
|
|
8b22ab93c1 | ||
|
|
78e3a2d0c6 | ||
|
|
241c89e885 | ||
|
|
7d96a2a620 | ||
|
|
08104eec56 | ||
|
|
0fc81d6b33 | ||
|
|
cb84e55c34 | ||
|
|
68c3d5a159 | ||
|
|
77bab39ed0 | ||
|
|
92e570ffc1 | ||
|
|
919684e20a | ||
|
|
a1d6df6ce9 | ||
|
|
07c3c3bba8 | ||
|
|
f11e040662 | ||
|
|
8d83341308 | ||
|
|
f27b2c4df1 | ||
|
|
717b84bab9 | ||
|
|
a34bce6202 | ||
|
|
bd190b9b4c | ||
|
|
da6c6c5201 | ||
|
|
f50afae1c3 | ||
|
|
177afea5ad | ||
|
|
a3aae68229 | ||
|
|
9ff9d9230e | ||
|
|
2bb0843c30 | ||
|
|
5f2425f421 | ||
|
|
e46ca41697 | ||
|
|
fa5a7aea7e | ||
|
|
030681a443 | ||
|
|
aa3cbf2473 | ||
|
|
ce71f6444c | ||
|
|
eb4d561b96 | ||
|
|
075a41c69a | ||
|
|
2ba2248f67 | ||
|
|
ff5ed82de8 | ||
|
|
541b969d3b | ||
|
|
3d83c6299b | ||
|
|
3ecde49dca | ||
|
|
c1fcd8ea7f | ||
|
|
78ac8ba841 | ||
|
|
d9cfab4c8e | ||
|
|
4c10502b0e | ||
|
|
a576f7baf3 | ||
|
|
520c964656 | ||
|
|
3f59b1c376 | ||
|
|
3ff095cc51 | ||
|
|
aa4c41abe8 | ||
|
|
906b3901fb | ||
|
|
2aba4f261f | ||
|
|
3eb0c8ddff | ||
|
|
705a987547 | ||
|
|
888f17c504 | ||
|
|
2f4d0ede0f | ||
|
|
6fd9857666 | ||
|
|
f07265ece4 | ||
|
|
a169d6ca97 | ||
|
|
ebed38c1dc | ||
|
|
5619042fe7 | ||
|
|
67b3428b07 | ||
|
|
2302a3bb33 | ||
|
|
a83eafd949 | ||
|
|
2956f4fea1 | ||
|
|
180e1f462c | ||
|
|
2dc63eb8c5 | ||
|
|
4c40ec4948 | ||
|
|
56b3dc02a7 | ||
|
|
db5bcd9fc4 | ||
|
|
c845f4e9b2 | ||
|
|
5aff3499a0 | ||
|
|
a501451038 | ||
|
|
0deed82bea | ||
|
|
f9231de824 | ||
|
|
757c66613d | ||
|
|
9d2302f2f5 | ||
|
|
0bbbd2cd54 | ||
|
|
dbc15a2dda | ||
|
|
7fefd58b84 | ||
|
|
87b60967a6 | ||
|
|
e80069545f | ||
|
|
be5685695e | ||
|
|
6b769ac263 | ||
|
|
9114816384 | ||
|
|
db3e596e48 | ||
|
|
bdc21da076 | ||
|
|
a500eeb831 | ||
|
|
119d0c576a | ||
|
|
38cee53999 | ||
|
|
2ca9d4689e | ||
|
|
8a32ffc7b9 | ||
|
|
6475b1a446 | ||
|
|
07db244f91 | ||
|
|
ff4aed1f6e | ||
|
|
3208815e10 | ||
|
|
b4a1bdcb83 | ||
|
|
97869636f8 | ||
|
|
cbb092f792 | ||
|
|
0c5ee37721 | ||
|
|
e74aeeab1a | ||
|
|
b8df9c7e97 | ||
|
|
82a9e67b7e | ||
|
|
7410b8778a | ||
|
|
3e92f23680 | ||
|
|
3942e6a841 | ||
|
|
e76b483067 | ||
|
|
3de740ed1e | ||
|
|
bbe975baef | ||
|
|
6dff975711 | ||
|
|
71108d9ca0 | ||
|
|
053e5417a7 | ||
|
|
9bbc49e842 | ||
|
|
270780ef5f | ||
|
|
e15963b422 | ||
|
|
52e8196d0a | ||
|
|
cc62943835 | ||
|
|
d195726ed2 | ||
|
|
50e6c83dd8 | ||
|
|
3a58d97496 | ||
|
|
ace12958d1 | ||
|
|
d33a0f75fd | ||
|
|
d24a60777b | ||
|
|
f2a3a5cbbd | ||
|
|
3bf9908789 | ||
|
|
912798ee34 | ||
|
|
28990e1db5 | ||
|
|
e8281bb009 | ||
|
|
334f9deaec | ||
|
|
1d47dc41c9 | ||
|
|
66ecc4d69d | ||
|
|
fa3edb5c01 | ||
|
|
ea046f32be | ||
|
|
fd09476b28 | ||
|
|
7c306acd5d | ||
|
|
9c4733595a | ||
|
|
c7cf9585ae | ||
|
|
301ca88f41 | ||
|
|
9a0fed89bd | ||
|
|
2050b0b375 | ||
|
|
34c7c3f384 | ||
|
|
3b9d8e00bc | ||
|
|
6b35b069b2 | ||
|
|
9428127021 | ||
|
|
1e8843947c | ||
|
|
dbdffbba23 | ||
|
|
460f02ede5 | ||
|
|
0eb6c88bc5 | ||
|
|
4b7650f2d2 | ||
|
|
8004c6605b | ||
|
|
9d451b6358 | ||
|
|
7963665c40 | ||
|
|
d44a34ce1e | ||
|
|
49b7559b1f | ||
|
|
43b1dd64a7 | ||
|
|
2d0c1fac24 | ||
|
|
a0f35a84ae | ||
|
|
4bc5987f36 | ||
|
|
11644d48ee | ||
|
|
d273a92a19 | ||
|
|
b0ff4b5841 | ||
|
|
ef99658919 | ||
|
|
a9238c7577 | ||
|
|
993e98a43f | ||
|
|
10dd11f257 | ||
|
|
fb9be3da79 | ||
|
|
3b1a33d606 | ||
|
|
710e18f399 | ||
|
|
67b9904740 | ||
|
|
e413e9b93b | ||
|
|
5c86042b31 | ||
|
|
e89333811e | ||
|
|
4f723232e3 | ||
|
|
48520d90ef | ||
|
|
2fdda91cb8 | ||
|
|
c023f610dd | ||
|
|
161b62d8fa | ||
|
|
8ccedd4064 | ||
|
|
9a06584a1d | ||
|
|
a21e586140 | ||
|
|
91f01d660f | ||
|
|
1748dbd60f | ||
|
|
5acae7f86d | ||
|
|
30ecba9944 | ||
|
|
4287df5f3d | ||
|
|
063deab3cb | ||
|
|
27798a6004 | ||
|
|
577ddd9021 | ||
|
|
34663e160d | ||
|
|
ac54b81289 | ||
|
|
67174fb07e | ||
|
|
d2a692393f | ||
|
|
9aa2664188 | ||
|
|
ab5d60e33d | ||
|
|
31847d8cfb | ||
|
|
9729f1f38b | ||
|
|
6bc6733c40 | ||
|
|
b1ffcb4245 | ||
|
|
f0c5fbfb8a | ||
|
|
c76239806d | ||
|
|
6d809b0b5a | ||
|
|
de2cbb7f5c | ||
|
|
cd61f37df7 | ||
|
|
26796f87cd | ||
|
|
e2dd897ac7 | ||
|
|
3bbe4baaf7 | ||
|
|
d409b86217 | ||
|
|
7928c15849 | ||
|
|
d197debbc0 | ||
|
|
55b9dee448 | ||
|
|
5c6984d326 | ||
|
|
a7787d6080 | ||
|
|
2db60340c2 | ||
|
|
c121631fef | ||
|
|
b0fb16d48d | ||
|
|
3e07f6543e | ||
|
|
d4c2356c70 | ||
|
|
eec617b391 | ||
|
|
b15c9ad130 | ||
|
|
0128d85999 | ||
|
|
e69ca0cf80 | ||
|
|
0719753be3 | ||
|
|
ba3181d4e7 | ||
|
|
e58750555e | ||
|
|
026687299d | ||
|
|
3eed552c56 | ||
|
|
15a4514c7d | ||
|
|
b5445c0061 | ||
|
|
1d0584a90d | ||
|
|
158b795c70 | ||
|
|
4994229215 | ||
|
|
c022c32d2f | ||
|
|
d2ef3ca100 | ||
|
|
00faadcfea | ||
|
|
a6ff52b300 | ||
|
|
da0d65ca5b | ||
|
|
2266e97417 | ||
|
|
d471de5645 | ||
|
|
38674f0dc2 | ||
|
|
b192ca4bad | ||
|
|
73a59523f5 | ||
|
|
05324dedd0 | ||
|
|
f1e5f73d7e | ||
|
|
7b23f21712 | ||
|
|
4dde314338 | ||
|
|
cba12fb598 | ||
|
|
63e38b4d8d | ||
|
|
7eded95315 | ||
|
|
e493fe1105 | ||
|
|
646c230940 | ||
|
|
5276a3688e | ||
|
|
0616bf16f4 | ||
|
|
fbe1811e2b | ||
|
|
2333c10915 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
42
.github/workflows/ci.yaml
vendored
42
.github/workflows/ci.yaml
vendored
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.7.0
|
||||
uses: actions/dependency-review-action@v4.7.1
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -944,7 +944,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1020,6 +1021,12 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1070,7 +1077,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1154,6 +1162,12 @@ jobs:
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1202,7 +1216,8 @@ jobs:
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
@@ -1290,6 +1305,12 @@ jobs:
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1320,7 +1341,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1357,7 +1378,8 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1436,6 +1458,12 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
overwrite: true
|
||||
- name: Beautify test results
|
||||
# For easier identification of parsing errors
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
run: |
|
||||
xmllint --format "junit.xml" > "junit.xml-tmp"
|
||||
mv "junit.xml-tmp" "junit.xml"
|
||||
- name: Upload test results artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1463,7 +1491,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.17
|
||||
uses: github/codeql-action/init@v3.28.18
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.17
|
||||
uses: github/codeql-action/analyze@v3.28.18
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -270,6 +270,7 @@ homeassistant.components.image_processing.*
|
||||
homeassistant.components.image_upload.*
|
||||
homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
|
||||
21
CODEOWNERS
generated
21
CODEOWNERS
generated
@@ -202,8 +202,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/blebox/ @bbx-a @swistakm
|
||||
/homeassistant/components/blink/ @fronzbot @mkmer
|
||||
/tests/components/blink/ @fronzbot @mkmer
|
||||
/homeassistant/components/blue_current/ @Floris272 @gleeuwen
|
||||
/tests/components/blue_current/ @Floris272 @gleeuwen
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/homeassistant/components/bluemaestro/ @bdraco
|
||||
/tests/components/bluemaestro/ @bdraco
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
@@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||
/homeassistant/components/imgw_pib/ @bieniu
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
@@ -1138,6 +1140,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/palazzetti/ @dotvav
|
||||
/homeassistant/components/panel_custom/ @home-assistant/frontend
|
||||
/tests/components/panel_custom/ @home-assistant/frontend
|
||||
/homeassistant/components/paperless_ngx/ @fvgarrel
|
||||
/tests/components/paperless_ngx/ @fvgarrel
|
||||
/homeassistant/components/peblar/ @frenck
|
||||
/tests/components/peblar/ @frenck
|
||||
/homeassistant/components/peco/ @IceBotYT
|
||||
@@ -1176,6 +1180,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/private_ble_device/ @Jc2k
|
||||
/tests/components/private_ble_device/ @Jc2k
|
||||
/homeassistant/components/probe_plus/ @pantherale0
|
||||
/tests/components/probe_plus/ @pantherale0
|
||||
/homeassistant/components/profiler/ @bdraco
|
||||
/tests/components/profiler/ @bdraco
|
||||
/homeassistant/components/progettihwsw/ @ardaseremet
|
||||
@@ -1222,6 +1228,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/qnap_qsw/ @Noltari
|
||||
/tests/components/qnap_qsw/ @Noltari
|
||||
/homeassistant/components/quantum_gateway/ @cisasteelersfan
|
||||
/tests/components/quantum_gateway/ @cisasteelersfan
|
||||
/homeassistant/components/qvr_pro/ @oblogic7
|
||||
/homeassistant/components/qwikswitch/ @kellerza
|
||||
/tests/components/qwikswitch/ @kellerza
|
||||
@@ -1410,6 +1417,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/components/smappee/ @bsmappee
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
@@ -1484,8 +1493,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/subaru/ @G-Two
|
||||
/homeassistant/components/suez_water/ @ooii @jb101010-2
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @Swamp-Ig
|
||||
/tests/components/sun/ @Swamp-Ig
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
@@ -1498,8 +1507,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switch_as_x/ @home-assistant/core
|
||||
/homeassistant/components/switchbee/ @jafar-atili
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Concatenate
|
||||
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
|
||||
model_id=measures.model,
|
||||
serial_number=coordinator.serial_number,
|
||||
sw_version=measures.firmware_version,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode]
|
||||
|
||||
@property
|
||||
def hvac_modes(self):
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available operation modes."""
|
||||
airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number)
|
||||
modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes]
|
||||
@@ -226,12 +226,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
def min_temp(self) -> float:
|
||||
"""Return Minimum Temperature for AC of this group."""
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
def max_temp(self) -> float:
|
||||
"""Return Max Temperature for AC of this group."""
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@ class AnthropicConversationEntity(
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyaprilaire"],
|
||||
"requirements": ["pyaprilaire==0.8.1"]
|
||||
"requirements": ["pyaprilaire==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ import hass_nabucasa
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation, stt, tts, wake_word, websocket_api
|
||||
from homeassistant.components.tts import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -92,6 +89,8 @@ KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
|
||||
KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey(
|
||||
"pipeline_conversation_data"
|
||||
)
|
||||
# Number of response parts to handle before streaming the response
|
||||
STREAM_RESPONSE_CHARS = 60
|
||||
|
||||
|
||||
def validate_language(data: dict[str, Any]) -> Any:
|
||||
@@ -555,7 +554,7 @@ class PipelineRun:
|
||||
event_callback: PipelineEventCallback
|
||||
language: str = None # type: ignore[assignment]
|
||||
runner_data: Any | None = None
|
||||
intent_agent: str | None = None
|
||||
intent_agent: conversation.AgentInfo | None = None
|
||||
tts_audio_output: str | dict[str, Any] | None = None
|
||||
wake_word_settings: WakeWordSettings | None = None
|
||||
audio_settings: AudioSettings = field(default_factory=AudioSettings)
|
||||
@@ -591,6 +590,9 @@ class PipelineRun:
|
||||
_intent_agent_only = False
|
||||
"""If request should only be handled by agent, ignoring sentence triggers and local processing."""
|
||||
|
||||
_streamed_response_text = False
|
||||
"""If the conversation agent streamed response text to TTS result."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Set language for pipeline."""
|
||||
self.language = self.pipeline.language or self.hass.config.language
|
||||
@@ -652,6 +654,11 @@ class PipelineRun:
|
||||
"token": self.tts_stream.token,
|
||||
"url": self.tts_stream.url,
|
||||
"mime_type": self.tts_stream.content_type,
|
||||
"stream_response": (
|
||||
self.tts_stream.supports_streaming_input
|
||||
and self.intent_agent
|
||||
and self.intent_agent.supports_streaming
|
||||
),
|
||||
}
|
||||
|
||||
self.process_event(PipelineEvent(PipelineEventType.RUN_START, data))
|
||||
@@ -899,12 +906,12 @@ class PipelineRun:
|
||||
) -> str:
|
||||
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
|
||||
# Create a background task to prepare the conversation agent
|
||||
if self.end_stage >= PipelineStage.INTENT:
|
||||
if self.end_stage >= PipelineStage.INTENT and self.intent_agent:
|
||||
self.hass.async_create_background_task(
|
||||
conversation.async_prepare_agent(
|
||||
self.hass, self.intent_agent, self.language
|
||||
self.hass, self.intent_agent.id, self.language
|
||||
),
|
||||
f"prepare conversation agent {self.intent_agent}",
|
||||
f"prepare conversation agent {self.intent_agent.id}",
|
||||
)
|
||||
|
||||
if isinstance(self.stt_provider, stt.Provider):
|
||||
@@ -1045,7 +1052,7 @@ class PipelineRun:
|
||||
message=f"Intent recognition engine {engine} is not found",
|
||||
)
|
||||
|
||||
self.intent_agent = agent_info.id
|
||||
self.intent_agent = agent_info
|
||||
|
||||
async def recognize_intent(
|
||||
self,
|
||||
@@ -1078,7 +1085,7 @@ class PipelineRun:
|
||||
PipelineEvent(
|
||||
PipelineEventType.INTENT_START,
|
||||
{
|
||||
"engine": self.intent_agent,
|
||||
"engine": self.intent_agent.id,
|
||||
"language": input_language,
|
||||
"intent_input": intent_input,
|
||||
"conversation_id": conversation_id,
|
||||
@@ -1095,11 +1102,11 @@ class PipelineRun:
|
||||
conversation_id=conversation_id,
|
||||
device_id=device_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent,
|
||||
agent_id=self.intent_agent.id,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
agent_id = self.intent_agent
|
||||
agent_id = self.intent_agent.id
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
@@ -1121,7 +1128,7 @@ class PipelineRun:
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent)
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
@@ -1143,6 +1150,13 @@ class PipelineRun:
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
processed_locally = True
|
||||
|
||||
if self.tts_stream and self.tts_stream.supports_streaming_input:
|
||||
tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
|
||||
else:
|
||||
tts_input_stream = None
|
||||
chat_log_role = None
|
||||
delta_character_count = 0
|
||||
|
||||
@callback
|
||||
def chat_log_delta_listener(
|
||||
chat_log: conversation.ChatLog, delta: dict
|
||||
@@ -1156,6 +1170,42 @@ class PipelineRun:
|
||||
},
|
||||
)
|
||||
)
|
||||
if tts_input_stream is None:
|
||||
return
|
||||
|
||||
nonlocal chat_log_role
|
||||
|
||||
if role := delta.get("role"):
|
||||
chat_log_role = role
|
||||
|
||||
# We are only interested in assistant deltas with content
|
||||
if chat_log_role != "assistant" or not (
|
||||
content := delta.get("content")
|
||||
):
|
||||
return
|
||||
|
||||
tts_input_stream.put_nowait(content)
|
||||
|
||||
if self._streamed_response_text:
|
||||
return
|
||||
|
||||
nonlocal delta_character_count
|
||||
|
||||
delta_character_count += len(content)
|
||||
if delta_character_count < STREAM_RESPONSE_CHARS:
|
||||
return
|
||||
|
||||
# Streamed responses are not cached. We only start streaming text after
|
||||
# we have received a couple of words that indicates it will be a long response.
|
||||
self._streamed_response_text = True
|
||||
|
||||
async def tts_input_stream_generator() -> AsyncGenerator[str]:
|
||||
"""Yield TTS input stream."""
|
||||
while (tts_input := await tts_input_stream.get()) is not None:
|
||||
yield tts_input
|
||||
|
||||
assert self.tts_stream is not None
|
||||
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
|
||||
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
@@ -1199,6 +1249,8 @@ class PipelineRun:
|
||||
speech = conversation_result.response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
)
|
||||
if tts_input_stream and self._streamed_response_text:
|
||||
tts_input_stream.put_nowait(None)
|
||||
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during intent recognition")
|
||||
@@ -1276,26 +1328,11 @@ class PipelineRun:
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Synthesize audio and get URL
|
||||
tts_media_id = tts_generate_media_source_id(
|
||||
self.hass,
|
||||
tts_input,
|
||||
engine=self.tts_stream.engine,
|
||||
language=self.tts_stream.language,
|
||||
options=self.tts_stream.options,
|
||||
)
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during text-to-speech")
|
||||
raise TextToSpeechError(
|
||||
code="tts-failed",
|
||||
message="Unexpected error during text-to-speech",
|
||||
) from src_error
|
||||
|
||||
self.tts_stream.async_set_message(tts_input)
|
||||
if not self._streamed_response_text:
|
||||
self.tts_stream.async_set_message(tts_input)
|
||||
|
||||
tts_output = {
|
||||
"media_id": tts_media_id,
|
||||
"media_id": self.tts_stream.media_source_id,
|
||||
"token": self.tts_stream.token,
|
||||
"url": self.tts_stream.url,
|
||||
"mime_type": self.tts_stream.content_type,
|
||||
|
||||
@@ -47,7 +47,7 @@ from .const import (
|
||||
CONF_VIDEO_SOURCE,
|
||||
DEFAULT_STREAM_PROFILE,
|
||||
DEFAULT_VIDEO_SOURCE,
|
||||
DOMAIN as AXIS_DOMAIN,
|
||||
DOMAIN,
|
||||
)
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
@@ -58,7 +58,7 @@ DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
|
||||
class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Axis config flow."""
|
||||
|
||||
VERSION = 3
|
||||
@@ -146,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN)
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from azure.core.exceptions import (
|
||||
AzureError,
|
||||
ClientAuthenticationError,
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
@@ -39,11 +39,20 @@ async def async_setup_entry(
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
"""Create a ContainerClient."""
|
||||
|
||||
return ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
# has a blocking call to open in cpython
|
||||
container_client: ContainerClient = await hass.async_add_executor_job(
|
||||
create_container_client
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -61,7 +70,7 @@ async def async_setup_entry(
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except HttpResponseError as err:
|
||||
except AzureError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.core.exceptions import AzureError, HttpResponseError, ServiceRequestError
|
||||
from azure.storage.blob import BlobProperties
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
@@ -80,6 +80,20 @@ def handle_backup_errors[_R, **P](
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
except ServiceRequestError as err:
|
||||
raise BackupAgentError(
|
||||
f"Timeout during backup operation in {func.__name__}"
|
||||
) from err
|
||||
except AzureError as err:
|
||||
_LOGGER.debug(
|
||||
"Error during backup in %s: %s",
|
||||
func.__name__,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}: {err}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -27,9 +27,25 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
def get_account_url(self, account_name: str) -> str:
|
||||
"""Get the account URL."""
|
||||
return f"https://{account_name}.blob.core.windows.net/"
|
||||
async def get_container_client(
|
||||
self, account_name: str, container_name: str, storage_account_key: str
|
||||
) -> ContainerClient:
|
||||
"""Get the container client.
|
||||
|
||||
ContainerClient has a blocking call to open in cpython
|
||||
"""
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
return ContainerClient(
|
||||
account_url=f"https://{account_name}.blob.core.windows.net/",
|
||||
container_name=container_name,
|
||||
credential=storage_account_key,
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
return await self.hass.async_add_executor_job(create_container_client)
|
||||
|
||||
async def validate_config(
|
||||
self, container_client: ContainerClient
|
||||
@@ -58,11 +74,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=user_input[CONF_ACCOUNT_NAME],
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
|
||||
@@ -99,12 +114,12 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reauth_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_name=reauth_entry.data[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -129,13 +144,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(
|
||||
reconfigure_entry.data[CONF_ACCOUNT_NAME]
|
||||
),
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reconfigure_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
|
||||
@@ -23,6 +23,7 @@ from .const import DATA_MANAGER, DOMAIN
|
||||
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
|
||||
from .http import async_register_http_views
|
||||
from .manager import (
|
||||
AddonErrorData,
|
||||
BackupManager,
|
||||
BackupManagerError,
|
||||
BackupPlatformEvent,
|
||||
@@ -48,6 +49,7 @@ from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
__all__ = [
|
||||
"AddonErrorData",
|
||||
"AddonInfo",
|
||||
"AgentBackup",
|
||||
"BackupAgent",
|
||||
|
||||
@@ -106,11 +106,21 @@ class ManagerBackup(BaseBackup):
|
||||
with_automatic_settings: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class AddonErrorData:
|
||||
"""Addon error class."""
|
||||
|
||||
name: str
|
||||
errors: list[tuple[str, str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class WrittenBackup:
|
||||
"""Written backup class."""
|
||||
|
||||
addon_errors: dict[str, AddonErrorData]
|
||||
backup: AgentBackup
|
||||
folder_errors: dict[Folder, list[tuple[str, str]]]
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]]
|
||||
release_stream: Callable[[], Coroutine[Any, Any, None]]
|
||||
|
||||
@@ -1208,7 +1218,9 @@ class BackupManager:
|
||||
backup_success = True
|
||||
|
||||
if with_automatic_settings:
|
||||
self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
|
||||
self._update_issue_after_agent_upload(
|
||||
written_backup, agent_errors, unavailable_agents
|
||||
)
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
@@ -1354,8 +1366,10 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
def _update_issue_backup_failed(self) -> None:
|
||||
"""Update issue registry when a backup fails."""
|
||||
def _create_automatic_backup_failed_issue(
|
||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||
) -> None:
|
||||
"""Create an issue in the issue registry for automatic backup failures."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
@@ -1364,37 +1378,64 @@ class BackupManager:
|
||||
is_persistent=True,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_failed_create",
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
def _update_issue_backup_failed(self) -> None:
|
||||
"""Update issue registry when a backup fails."""
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_create", None
|
||||
)
|
||||
|
||||
def _update_issue_after_agent_upload(
|
||||
self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
|
||||
self,
|
||||
written_backup: WrittenBackup,
|
||||
agent_errors: dict[str, Exception],
|
||||
unavailable_agents: list[str],
|
||||
) -> None:
|
||||
"""Update issue registry after a backup is uploaded to agents."""
|
||||
if not agent_errors and not unavailable_agents:
|
||||
|
||||
addon_errors = written_backup.addon_errors
|
||||
failed_agents = unavailable_agents + [
|
||||
self.backup_agents[agent_id].name for agent_id in agent_errors
|
||||
]
|
||||
folder_errors = written_backup.folder_errors
|
||||
|
||||
if not failed_agents and not addon_errors and not folder_errors:
|
||||
# No issues to report, clear previous error
|
||||
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
|
||||
return
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"automatic_backup_failed",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_failed_upload_agents",
|
||||
translation_placeholders={
|
||||
"failed_agents": ", ".join(
|
||||
chain(
|
||||
(
|
||||
self.backup_agents[agent_id].name
|
||||
for agent_id in agent_errors
|
||||
),
|
||||
unavailable_agents,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
if failed_agents and not (addon_errors or folder_errors):
|
||||
# No issues with add-ons or folders, but issues with agents
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_upload_agents",
|
||||
{"failed_agents": ", ".join(failed_agents)},
|
||||
)
|
||||
elif addon_errors and not (failed_agents or folder_errors):
|
||||
# No issues with agents or folders, but issues with add-ons
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_addons",
|
||||
{"failed_addons": ", ".join(val.name for val in addon_errors.values())},
|
||||
)
|
||||
elif folder_errors and not (failed_agents or addon_errors):
|
||||
# No issues with agents or add-ons, but issues with folders
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_folders",
|
||||
{"failed_folders": ", ".join(folder for folder in folder_errors)},
|
||||
)
|
||||
else:
|
||||
# Issues with agents, add-ons, and/or folders
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_agents_addons_folders",
|
||||
{
|
||||
"failed_agents": ", ".join(failed_agents) or "-",
|
||||
"failed_addons": (
|
||||
", ".join(val.name for val in addon_errors.values()) or "-"
|
||||
),
|
||||
"failed_folders": ", ".join(f for f in folder_errors) or "-",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_can_decrypt_on_download(
|
||||
self,
|
||||
@@ -1677,7 +1718,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
|
||||
return WrittenBackup(
|
||||
backup=backup, open_stream=open_backup, release_stream=remove_backup
|
||||
addon_errors={},
|
||||
backup=backup,
|
||||
folder_errors={},
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
@@ -1816,7 +1861,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
await async_add_executor_job(temp_file.unlink, True)
|
||||
|
||||
return WrittenBackup(
|
||||
backup=backup, open_stream=open_backup, release_stream=remove_backup
|
||||
addon_errors={},
|
||||
backup=backup,
|
||||
folder_errors={},
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
|
||||
async def async_restore_backup(
|
||||
|
||||
@@ -77,7 +77,10 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
for agent in data["config"]["agents"]:
|
||||
data["config"]["agents"][agent]["retention"] = None
|
||||
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Note: We allow reading data with major version 2 in which the unused key
|
||||
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
|
||||
# planned to happen after a 6 month quiet period with no minor version
|
||||
# changes.
|
||||
# Reject if major version is higher than 2.
|
||||
if old_major_version > 2:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
"automatic_backup_failed_upload_agents": {
|
||||
"title": "Automatic backup could not be uploaded to the configured locations",
|
||||
"description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_addons": {
|
||||
"title": "Not all add-ons could be included in automatic backup",
|
||||
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_agents_addons_folders": {
|
||||
"title": "Automatic backup was created with errors",
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_folders": {
|
||||
"title": "Not all folders could be included in automatic backup",
|
||||
"description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -21,7 +21,6 @@ from .entity import BleBoxEntity
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
None: None,
|
||||
0: HVACMode.OFF,
|
||||
1: HVACMode.HEAT,
|
||||
2: HVACMode.COOL,
|
||||
@@ -59,12 +58,14 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_modes(self):
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return list of supported HVAC modes."""
|
||||
if self._feature.mode is None:
|
||||
return [HVACMode.OFF]
|
||||
return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]]
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the desired HVAC mode."""
|
||||
if self._feature.is_on is None:
|
||||
return None
|
||||
@@ -75,7 +76,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF
|
||||
|
||||
@property
|
||||
def hvac_action(self):
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the actual current HVAC action."""
|
||||
if self._feature.hvac_action is not None:
|
||||
if not self._feature.is_on:
|
||||
@@ -88,22 +89,22 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
return HVACAction.HEATING if self._feature.is_heating else HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature supported."""
|
||||
return self._feature.max_temp
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
def min_temp(self) -> float:
|
||||
"""Return the maximum temperature supported."""
|
||||
return self._feature.min_temp
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._feature.current
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE
|
||||
|
||||
type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
89
homeassistant/components/blue_current/button.py
Normal file
89
homeassistant/components/blue_current/button.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Support for Blue Current buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from bluecurrent_api.client import Client
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BlueCurrentConfigEntry, Connector
|
||||
from .entity import ChargepointEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ChargePointButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes a Blue Current button entity."""
|
||||
|
||||
function: Callable[[Client, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
CHARGE_POINT_BUTTONS = (
|
||||
ChargePointButtonEntityDescription(
|
||||
key="reset",
|
||||
translation_key="reset",
|
||||
function=lambda client, evse_id: client.reset(evse_id),
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ChargePointButtonEntityDescription(
|
||||
key="reboot",
|
||||
translation_key="reboot",
|
||||
function=lambda client, evse_id: client.reboot(evse_id),
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ChargePointButtonEntityDescription(
|
||||
key="stop_charge_session",
|
||||
translation_key="stop_charge_session",
|
||||
function=lambda client, evse_id: client.stop_session(evse_id),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BlueCurrentConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Blue Current buttons."""
|
||||
connector: Connector = entry.runtime_data
|
||||
async_add_entities(
|
||||
ChargePointButton(
|
||||
connector,
|
||||
button,
|
||||
evse_id,
|
||||
)
|
||||
for evse_id in connector.charge_points
|
||||
for button in CHARGE_POINT_BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class ChargePointButton(ChargepointEntity, ButtonEntity):
|
||||
"""Define a charge point button."""
|
||||
|
||||
has_value = True
|
||||
entity_description: ChargePointButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector: Connector,
|
||||
description: ChargePointButtonEntityDescription,
|
||||
evse_id: str,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(connector, evse_id)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{description.key}_{evse_id}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.entity_description.function(self.connector.client, self.evse_id)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Entity representing a Blue Current charge point."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -17,12 +15,12 @@ class BlueCurrentEntity(Entity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
has_value = False
|
||||
|
||||
def __init__(self, connector: Connector, signal: str) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.connector = connector
|
||||
self.signal = signal
|
||||
self.has_value = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -43,7 +41,6 @@ class BlueCurrentEntity(Entity):
|
||||
return self.connector.connected and self.has_value
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@
|
||||
"current_left": {
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"reset": {
|
||||
"default": "mdi:restart"
|
||||
},
|
||||
"reboot": {
|
||||
"default": "mdi:restart-alert"
|
||||
},
|
||||
"stop_charge_session": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blue_current",
|
||||
"name": "Blue Current",
|
||||
"codeowners": ["@Floris272", "@gleeuwen"],
|
||||
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blue_current",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -113,6 +113,17 @@
|
||||
"grid_max_current": {
|
||||
"name": "Max grid current"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"stop_charge_session": {
|
||||
"name": "Stop charge session"
|
||||
},
|
||||
"reboot": {
|
||||
"name": "Reboot"
|
||||
},
|
||||
"reset": {
|
||||
"name": "Reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"bleak==0.22.3",
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.5.1",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.1",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.48.2"
|
||||
|
||||
@@ -22,13 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import (
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
DOMAIN as BMW_DOMAIN,
|
||||
SCAN_INTERVALS,
|
||||
)
|
||||
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,7 +57,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
update_interval=timedelta(
|
||||
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
|
||||
),
|
||||
@@ -81,26 +75,26 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
raise UpdateFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
# Clear refresh token and trigger reauth if previous update failed as well
|
||||
self._update_config_entry_refresh_token(None)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (MyBMWAPIError, RequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -6,21 +6,31 @@ from ssl import SSLError
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
from .services import setup_services
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up bosch alarm services."""
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
|
||||
@@ -52,8 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
mac = entry.data.get(CONF_MAC)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
|
||||
@@ -12,8 +12,8 @@ from homeassistant.components.alarm_control_panel import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -34,6 +34,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||
"""An alarm control panel entity for a bosch alarm panel."""
|
||||
|
||||
|
||||
220
homeassistant/components/bosch_alarm/binary_sensor.py
Normal file
220
homeassistant/components/bosch_alarm/binary_sensor.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Support for Bosch Alarm Panel binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Bosch Alarm sensor entity."""
|
||||
|
||||
fault: int
|
||||
|
||||
|
||||
FAULT_TYPES = [
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_battery_low",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
fault=ALARM_PANEL_FAULTS.BATTERY_LOW,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_battery_mising",
|
||||
translation_key="panel_fault_battery_mising",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.BATTERY_MISING,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_ac_fail",
|
||||
translation_key="panel_fault_ac_fail",
|
||||
entity_registry_enabled_default=True,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.AC_FAIL,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_phone_line_failure",
|
||||
translation_key="panel_fault_phone_line_failure",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_parameter_crc_fail_in_pif",
|
||||
translation_key="panel_fault_parameter_crc_fail_in_pif",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_communication_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_communication_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_sdi_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_sdi_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_user_code_tamper_since_rps_hang_up",
|
||||
translation_key="panel_fault_user_code_tamper_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_fail_to_call_rps_since_rps_hang_up",
|
||||
translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_point_bus_fail_since_rps_hang_up",
|
||||
translation_key="panel_fault_point_bus_fail_since_rps_hang_up",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_log_overflow",
|
||||
translation_key="panel_fault_log_overflow",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW,
|
||||
),
|
||||
BoschAlarmFaultEntityDescription(
|
||||
key="panel_fault_log_threshold",
|
||||
translation_key="panel_fault_log_threshold",
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BoschAlarmConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors for alarm points and the connection status."""
|
||||
panel = config_entry.runtime_data
|
||||
|
||||
entities: list[BinarySensorEntity] = [
|
||||
PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id)
|
||||
for point_id in panel.points
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
PanelFaultsSensor(
|
||||
panel,
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
fault_type,
|
||||
)
|
||||
for fault_type in FAULT_TYPES
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
AreaReadyToArmSensor(
|
||||
panel, area_id, config_entry.unique_id or config_entry.entry_id, "away"
|
||||
)
|
||||
for area_id in panel.areas
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
AreaReadyToArmSensor(
|
||||
panel, area_id, config_entry.unique_id or config_entry.entry_id, "home"
|
||||
)
|
||||
for area_id in panel.areas
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity for each fault type in a bosch alarm panel."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
entity_description: BoschAlarmFaultEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
panel: Panel,
|
||||
unique_id: str,
|
||||
entity_description: BoschAlarmFaultEntityDescription,
|
||||
) -> None:
|
||||
"""Set up a binary sensor entity for each fault type in a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id, True)
|
||||
self.entity_description = entity_description
|
||||
self._fault_type = entity_description.fault
|
||||
self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this fault has occurred."""
|
||||
return self._fault_type in self.panel.panel_faults_ids
|
||||
|
||||
|
||||
class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity showing if a panel is ready to arm."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(
|
||||
self, panel: Panel, area_id: int, unique_id: str, arm_type: str
|
||||
) -> None:
|
||||
"""Set up a binary sensor entity for the arming status in a bosch alarm panel."""
|
||||
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||
self.panel = panel
|
||||
self._arm_type = arm_type
|
||||
self._attr_translation_key = f"area_ready_to_arm_{arm_type}"
|
||||
self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this panel is ready to arm."""
|
||||
if self._arm_type == "away":
|
||||
return self._area.all_ready
|
||||
if self._arm_type == "home":
|
||||
return self._area.all_ready or self._area.part_ready
|
||||
return False
|
||||
|
||||
|
||||
class PointSensor(BoschAlarmPointEntity, BinarySensorEntity):
|
||||
"""A binary sensor entity for a point in a bosch alarm panel."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
|
||||
"""Set up a binary sensor entity for a point in a bosch alarm panel."""
|
||||
super().__init__(panel, point_id, unique_id)
|
||||
self._attr_unique_id = self._point_unique_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if this point sensor is on."""
|
||||
return self._point.is_open()
|
||||
@@ -6,25 +6,30 @@ import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_DHCP,
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MODEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
@@ -88,6 +93,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Init config flow."""
|
||||
|
||||
self._data: dict[str, Any] = {}
|
||||
self.mac: str | None = None
|
||||
self.host: str | None = None
|
||||
|
||||
def is_matching(self, other_flow: Self) -> bool:
|
||||
"""Return True if other_flow is matching this flow."""
|
||||
return self.mac == other_flow.mac or self.host == other_flow.host
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -96,9 +107,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self.host = user_input[CONF_HOST]
|
||||
if self.source == SOURCE_USER:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
try:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, serial) = await try_connect(user_input, 0)
|
||||
(model, _) = await try_connect(user_input, 0)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
@@ -129,6 +143,70 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
self.mac = format_mac(discovery_info.macaddress)
|
||||
self.host = discovery_info.ip
|
||||
if self.hass.config_entries.flow.async_has_matching_flow(self):
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data.get(CONF_MAC) == self.mac:
|
||||
result = self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_HOST: discovery_info.ip,
|
||||
},
|
||||
)
|
||||
if result:
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
if entry.data[CONF_HOST] == discovery_info.ip:
|
||||
if (
|
||||
not entry.data.get(CONF_MAC)
|
||||
and entry.state is ConfigEntryState.LOADED
|
||||
):
|
||||
result = self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_MAC: self.mac,
|
||||
},
|
||||
)
|
||||
if result:
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
try:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, _) = await try_connect(
|
||||
{CONF_HOST: discovery_info.ip, CONF_PORT: 7700}, 0
|
||||
)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
ssl.SSLError,
|
||||
asyncio.exceptions.TimeoutError,
|
||||
):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
self.context["title_placeholders"] = {
|
||||
"model": model,
|
||||
"host": discovery_info.ip,
|
||||
}
|
||||
self._data = {
|
||||
CONF_HOST: discovery_info.ip,
|
||||
CONF_MAC: self.mac,
|
||||
CONF_MODEL: model,
|
||||
CONF_PORT: 7700,
|
||||
}
|
||||
|
||||
return await self.async_step_auth()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -172,7 +250,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
if self.source == SOURCE_USER:
|
||||
if self.source in (SOURCE_USER, SOURCE_DHCP):
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
@@ -184,6 +262,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_mismatch(reason="device_mismatch")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=self._data,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Constants for the Bosch Alarm integration."""
|
||||
|
||||
DOMAIN = "bosch_alarm"
|
||||
HISTORY_ATTR = "history"
|
||||
ATTR_HISTORY = "history"
|
||||
CONF_INSTALLER_CODE = "installer_code"
|
||||
CONF_USER_CODE = "user_code"
|
||||
ATTR_DATETIME = "datetime"
|
||||
SERVICE_SET_DATE_TIME = "set_date_time"
|
||||
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
||||
|
||||
@@ -6,8 +6,8 @@ from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
|
||||
|
||||
|
||||
@@ -17,9 +17,13 @@ class BoschAlarmEntity(Entity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, panel: Panel, unique_id: str) -> None:
|
||||
def __init__(
|
||||
self, panel: Panel, unique_id: str, observe_faults: bool = False
|
||||
) -> None:
|
||||
"""Set up a entity for a bosch alarm panel."""
|
||||
self.panel = panel
|
||||
self._observe_faults = observe_faults
|
||||
self._attr_should_poll = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
@@ -34,10 +38,14 @@ class BoschAlarmEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
||||
if self._observe_faults:
|
||||
self.panel.faults_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
||||
if self._observe_faults:
|
||||
self.panel.faults_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
@@ -88,6 +96,33 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmPointEntity(BoschAlarmEntity):
|
||||
"""A base entity for point related entities within a bosch alarm panel."""
|
||||
|
||||
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
|
||||
"""Set up a area related entity for a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id)
|
||||
self._point_id = point_id
|
||||
self._point_unique_id = f"{unique_id}_point_{point_id}"
|
||||
self._point = panel.points[point_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._point_unique_id)},
|
||||
name=self._point.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(DOMAIN, unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._point.status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._point.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmDoorEntity(BoschAlarmEntity):
|
||||
"""A base entity for area related entities within a bosch alarm panel."""
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"services": {
|
||||
"set_date_time": {
|
||||
"service": "mdi:clock-edit"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alarms_gas": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"alarms_fire": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"alarms_burglary": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"faulting_points": {
|
||||
"default": "mdi:alert-circle"
|
||||
}
|
||||
@@ -24,6 +38,44 @@
|
||||
"on": "mdi:lock-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"panel_fault_parameter_crc_fail_in_pif": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_phone_line_failure": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_sdi_fail_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_user_code_tamper_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_point_bus_fail_since_rps_hang_up": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_log_overflow": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"panel_fault_log_threshold": {
|
||||
"default": "mdi:alert-circle"
|
||||
},
|
||||
"area_ready_to_arm_away": {
|
||||
"default": "mdi:shield",
|
||||
"state": {
|
||||
"on": "mdi:shield-lock"
|
||||
}
|
||||
},
|
||||
"area_ready_to_arm_home": {
|
||||
"default": "mdi:shield",
|
||||
"state": {
|
||||
"on": "mdi:shield-home"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"name": "Bosch Alarm",
|
||||
"codeowners": ["@mag1024", "@sanjay900"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"macaddress": "000463*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -13,10 +13,7 @@ rules:
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
@@ -29,25 +26,22 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES
|
||||
from bosch_alarm_mode2.panel import Area
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -15,18 +16,53 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
|
||||
ALARM_TYPES = {
|
||||
"burglary": {
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.BURGLARY_ALARM: "alarm",
|
||||
},
|
||||
"gas": {
|
||||
ALARM_MEMORY_PRIORITIES.GAS_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.GAS_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.GAS_ALARM: "alarm",
|
||||
},
|
||||
"fire": {
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_SUPERVISORY: "supervisory",
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE: "trouble",
|
||||
ALARM_MEMORY_PRIORITIES.FIRE_ALARM: "alarm",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Bosch Alarm sensor entity."""
|
||||
|
||||
value_fn: Callable[[Area], int]
|
||||
value_fn: Callable[[Area], str | int]
|
||||
observe_alarms: bool = False
|
||||
observe_ready: bool = False
|
||||
observe_status: bool = False
|
||||
|
||||
|
||||
def priority_value_fn(priority_info: dict[int, str]) -> Callable[[Area], str]:
|
||||
"""Build a value_fn for a given priority type."""
|
||||
return lambda area: next(
|
||||
(key for priority, key in priority_info.items() if priority in area.alarms_ids),
|
||||
"no_issues",
|
||||
)
|
||||
|
||||
|
||||
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
|
||||
*[
|
||||
BoschAlarmSensorEntityDescription(
|
||||
key=f"alarms_{key}",
|
||||
translation_key=f"alarms_{key}",
|
||||
value_fn=priority_value_fn(priority_type),
|
||||
observe_alarms=True,
|
||||
)
|
||||
for key, priority_type in ALARM_TYPES.items()
|
||||
],
|
||||
BoschAlarmSensorEntityDescription(
|
||||
key="faulting_points",
|
||||
translation_key="faulting_points",
|
||||
@@ -81,6 +117,6 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
def native_value(self) -> str | int:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._area)
|
||||
|
||||
77
homeassistant/components/bosch_alarm/services.py
Normal file
77
homeassistant/components/bosch_alarm/services.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Services for the bosch_alarm integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
|
||||
def validate_datetime(value: Any) -> dt.datetime:
|
||||
"""Validate that a provided datetime is supported on a bosch alarm panel."""
|
||||
date_val = cv.datetime(value)
|
||||
if date_val.year < 2010:
|
||||
raise vol.RangeInvalid("datetime must be after 2009")
|
||||
|
||||
if date_val.year > 2037:
|
||||
raise vol.RangeInvalid("datetime must be before 2038")
|
||||
|
||||
return date_val
|
||||
|
||||
|
||||
SET_DATE_TIME_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_DATETIME): validate_datetime,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_set_panel_date(call: ServiceCall) -> None:
|
||||
"""Set the date and time on a bosch alarm panel."""
|
||||
config_entry: BoschAlarmConfigEntry | None
|
||||
value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now())
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": entry_id},
|
||||
)
|
||||
if config_entry.state is not ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": config_entry.title},
|
||||
)
|
||||
panel = config_entry.runtime_data
|
||||
try:
|
||||
await panel.set_panel_date(value)
|
||||
except asyncio.InvalidStateError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={"target": config_entry.title},
|
||||
) from err
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the bosch alarm integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_DATE_TIME,
|
||||
async_set_panel_date,
|
||||
schema=SET_DATE_TIME_SCHEMA,
|
||||
)
|
||||
12
homeassistant/components/bosch_alarm/services.yaml
Normal file
12
homeassistant/components/bosch_alarm/services.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
set_date_time:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: bosch_alarm
|
||||
datetime:
|
||||
required: false
|
||||
example: "2025-05-10 00:00:00"
|
||||
selector:
|
||||
datetime:
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{model} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -42,6 +43,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
@@ -49,6 +51,18 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Could not connect to \"{target}\"."
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "An unknown error occurred while setting the date and time on \"{target}\"."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Could not connect to panel."
|
||||
},
|
||||
@@ -56,22 +70,111 @@
|
||||
"message": "Incorrect credentials for panel."
|
||||
},
|
||||
"incorrect_door_state": {
|
||||
"message": "Door cannot be manipulated while it is being cycled."
|
||||
"message": "Door cannot be manipulated while it is momentarily unlocked."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_date_time": {
|
||||
"name": "Set date & time",
|
||||
"description": "Sets the date and time on the alarm panel.",
|
||||
"fields": {
|
||||
"datetime": {
|
||||
"name": "Date & time",
|
||||
"description": "The date and time to set. The time zone of the Home Assistant instance is assumed. If omitted, the current date and time is used."
|
||||
},
|
||||
"config_entry_id": {
|
||||
"name": "Config entry",
|
||||
"description": "The Bosch Alarm integration ID."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"panel_fault_battery_mising": {
|
||||
"name": "Battery missing"
|
||||
},
|
||||
"panel_fault_ac_fail": {
|
||||
"name": "AC Failure"
|
||||
},
|
||||
"panel_fault_parameter_crc_fail_in_pif": {
|
||||
"name": "CRC failure in panel configuration"
|
||||
},
|
||||
"panel_fault_phone_line_failure": {
|
||||
"name": "Phone line failure"
|
||||
},
|
||||
"panel_fault_sdi_fail_since_rps_hang_up": {
|
||||
"name": "SDI failure since last RPS connection"
|
||||
},
|
||||
"panel_fault_user_code_tamper_since_rps_hang_up": {
|
||||
"name": "User code tamper since last RPS connection"
|
||||
},
|
||||
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
|
||||
"name": "Failure to call RPS since last RPS connection"
|
||||
},
|
||||
"panel_fault_point_bus_fail_since_rps_hang_up": {
|
||||
"name": "Point bus failure since last RPS connection"
|
||||
},
|
||||
"panel_fault_log_overflow": {
|
||||
"name": "Log overflow"
|
||||
},
|
||||
"panel_fault_log_threshold": {
|
||||
"name": "Log threshold reached"
|
||||
},
|
||||
"area_ready_to_arm_away": {
|
||||
"name": "Area ready to arm away",
|
||||
"state": {
|
||||
"on": "Ready",
|
||||
"off": "Not ready"
|
||||
}
|
||||
},
|
||||
"area_ready_to_arm_home": {
|
||||
"name": "Area ready to arm home",
|
||||
"state": {
|
||||
"on": "Ready",
|
||||
"off": "Not ready"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"secured": {
|
||||
"name": "Secured"
|
||||
},
|
||||
"cycling": {
|
||||
"name": "Cycling"
|
||||
"name": "Momentarily unlocked"
|
||||
},
|
||||
"locked": {
|
||||
"name": "Locked"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"alarms_gas": {
|
||||
"name": "Gas alarm issues",
|
||||
"state": {
|
||||
"supervisory": "Supervisory",
|
||||
"trouble": "Trouble",
|
||||
"alarm": "Alarm",
|
||||
"no_issues": "No issues"
|
||||
}
|
||||
},
|
||||
"alarms_fire": {
|
||||
"name": "Fire alarm issues",
|
||||
"state": {
|
||||
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
|
||||
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
|
||||
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
|
||||
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
|
||||
}
|
||||
},
|
||||
"alarms_burglary": {
|
||||
"name": "Burglary alarm issues",
|
||||
"state": {
|
||||
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
|
||||
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
|
||||
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
|
||||
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
|
||||
}
|
||||
},
|
||||
"faulting_points": {
|
||||
"name": "Faulting points",
|
||||
"unit_of_measurement": "points"
|
||||
|
||||
7
homeassistant/components/bosch_alarm/types.py
Normal file
7
homeassistant/components/bosch_alarm/types.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Types for the Bosch Alarm integration."""
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
@@ -55,7 +55,6 @@ from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
deprecated_function,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -86,10 +85,10 @@ from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer,
|
||||
WebRTCAnswer, # noqa: F401
|
||||
WebRTCCandidate, # noqa: F401
|
||||
WebRTCClientConfiguration,
|
||||
WebRTCError,
|
||||
WebRTCError, # noqa: F401
|
||||
WebRTCMessage, # noqa: F401
|
||||
WebRTCSendMessage,
|
||||
async_get_supported_provider,
|
||||
@@ -473,9 +472,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self.async_update_token()
|
||||
self._create_stream_lock: asyncio.Lock | None = None
|
||||
self._webrtc_provider: CameraWebRTCProvider | None = None
|
||||
self._supports_native_sync_webrtc = (
|
||||
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
|
||||
)
|
||||
self._supports_native_async_webrtc = (
|
||||
type(self).async_handle_async_webrtc_offer
|
||||
!= Camera.async_handle_async_webrtc_offer
|
||||
@@ -579,15 +575,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""
|
||||
return None
|
||||
|
||||
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||
"""Handle the WebRTC offer and return an answer.
|
||||
|
||||
This is used by cameras with CameraEntityFeature.STREAM
|
||||
and StreamType.WEB_RTC.
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
|
||||
) -> None:
|
||||
@@ -600,42 +587,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
if self._supports_native_sync_webrtc:
|
||||
try:
|
||||
answer = await deprecated_function(
|
||||
"async_handle_async_webrtc_offer",
|
||||
breaks_in_ha_version="2025.6",
|
||||
)(self.async_handle_web_rtc_offer)(offer_sdp)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
str(ex),
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
# This catch was already here and should stay through the deprecation
|
||||
_LOGGER.error("Timeout handling WebRTC offer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"Timeout handling WebRTC offer",
|
||||
)
|
||||
)
|
||||
else:
|
||||
if answer:
|
||||
send_message(WebRTCAnswer(answer))
|
||||
else:
|
||||
_LOGGER.error("Error handling WebRTC offer: No answer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"No answer on WebRTC offer",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if self._webrtc_provider:
|
||||
await self._webrtc_provider.async_handle_async_webrtc_offer(
|
||||
self, offer_sdp, session_id, send_message
|
||||
@@ -764,9 +715,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
new_provider = None
|
||||
|
||||
# Skip all providers if the camera has a native WebRTC implementation
|
||||
if not (
|
||||
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
|
||||
):
|
||||
if not self._supports_native_async_webrtc:
|
||||
# Camera doesn't have a native WebRTC implementation
|
||||
new_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_provider
|
||||
@@ -798,17 +747,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
|
||||
if not self._supports_native_sync_webrtc:
|
||||
# Until 2024.11, the frontend was not resolving any ice servers
|
||||
# The async approach was added 2024.11 and new integrations need to use it
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
config.get_candidates_upfront = self._supports_native_sync_webrtc
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
return config
|
||||
|
||||
@@ -838,7 +782,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
||||
if self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
else:
|
||||
|
||||
@@ -111,13 +111,11 @@ class WebRTCClientConfiguration:
|
||||
|
||||
configuration: RTCConfiguration = field(default_factory=RTCConfiguration)
|
||||
data_channel: str | None = None
|
||||
get_candidates_upfront: bool = False
|
||||
|
||||
def to_frontend_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict that can be used by the frontend."""
|
||||
data: dict[str, Any] = {
|
||||
"configuration": self.configuration.to_dict(),
|
||||
"getCandidatesUpfront": self.get_candidates_upfront,
|
||||
}
|
||||
if self.data_channel is not None:
|
||||
data["dataChannel"] = self.data_channel
|
||||
|
||||
@@ -60,7 +60,7 @@ from .const import (
|
||||
ADDED_CAST_DEVICES_KEY,
|
||||
CAST_MULTIZONE_MANAGER_KEY,
|
||||
CONF_IGNORE_CEC,
|
||||
DOMAIN as CAST_DOMAIN,
|
||||
DOMAIN,
|
||||
SIGNAL_CAST_DISCOVERED,
|
||||
SIGNAL_CAST_REMOVED,
|
||||
SIGNAL_HASS_CAST_SHOW_VIEW,
|
||||
@@ -315,7 +315,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
self._cast_view_remove_handler: CALLBACK_TYPE | None = None
|
||||
self._attr_unique_id = str(cast_info.uuid)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))},
|
||||
identifiers={(DOMAIN, str(cast_info.uuid).replace("-", ""))},
|
||||
manufacturer=str(cast_info.cast_info.manufacturer),
|
||||
model=cast_info.cast_info.model_name,
|
||||
name=str(cast_info.friendly_name),
|
||||
@@ -591,7 +591,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
"""Generate root node."""
|
||||
children = []
|
||||
# Add media browsers
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
children.extend(
|
||||
await platform.async_get_media_browser_root_object(
|
||||
self.hass, self._chromecast.cast_type
|
||||
@@ -650,7 +650,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
platform: CastProtocol
|
||||
assert media_content_type is not None
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
browse_media = await platform.async_browse_media(
|
||||
self.hass,
|
||||
media_content_type,
|
||||
@@ -680,7 +680,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||
|
||||
# Handle media supported by a known cast app
|
||||
if media_type == CAST_DOMAIN:
|
||||
if media_type == DOMAIN:
|
||||
try:
|
||||
app_data = json.loads(media_id)
|
||||
if metadata := extra.get("metadata"):
|
||||
@@ -712,7 +712,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return
|
||||
|
||||
# Try the cast platforms
|
||||
for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
result = await platform.async_play_media(
|
||||
self.hass, self.entity_id, chromecast, media_type, media_id
|
||||
)
|
||||
|
||||
@@ -18,23 +18,20 @@ from homeassistant.const import (
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.temperature import display_temp as show_temp
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue
|
||||
from homeassistant.loader import async_suggest_report_issue
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .const import ( # noqa: F401
|
||||
ATTR_AUX_HEAT,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_FAN_MODE,
|
||||
@@ -77,7 +74,6 @@ from .const import ( # noqa: F401
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
PRESET_SLEEP,
|
||||
SERVICE_SET_AUX_HEAT,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HUMIDITY,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
@@ -168,12 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_handle_set_preset_mode_service",
|
||||
[ClimateEntityFeature.PRESET_MODE],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_SET_AUX_HEAT,
|
||||
{vol.Required(ATTR_AUX_HEAT): cv.boolean},
|
||||
async_service_aux_heat,
|
||||
[ClimateEntityFeature.AUX_HEAT],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
SET_TEMPERATURE_SCHEMA,
|
||||
@@ -239,7 +229,6 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"target_temperature_low",
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"is_aux_heat",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"swing_mode",
|
||||
@@ -279,7 +268,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_hvac_action: HVACAction | None = None
|
||||
_attr_hvac_mode: HVACMode | None
|
||||
_attr_hvac_modes: list[HVACMode]
|
||||
_attr_is_aux_heat: bool | None
|
||||
_attr_max_humidity: float = DEFAULT_MAX_HUMIDITY
|
||||
_attr_max_temp: float
|
||||
_attr_min_humidity: float = DEFAULT_MIN_HUMIDITY
|
||||
@@ -299,52 +287,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_target_temperature: float | None = None
|
||||
_attr_temperature_unit: str
|
||||
|
||||
__climate_reported_legacy_aux = False
|
||||
|
||||
def _report_legacy_aux(self) -> None:
|
||||
"""Log warning and create an issue if the entity implements legacy auxiliary heater."""
|
||||
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass,
|
||||
integration_domain=self.platform.platform_name,
|
||||
module=type(self).__module__,
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s implements the `is_aux_heat` property or uses the auxiliary "
|
||||
"heater methods in a subclass of ClimateEntity which is "
|
||||
"deprecated and will be unsupported from Home Assistant 2025.4."
|
||||
" Please %s"
|
||||
),
|
||||
self.platform.platform_name,
|
||||
self.__class__.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
translation_placeholders = {"platform": self.platform.platform_name}
|
||||
translation_key = "deprecated_climate_aux_no_url"
|
||||
issue_tracker = async_get_issue_tracker(
|
||||
self.hass,
|
||||
integration_domain=self.platform.platform_name,
|
||||
module=type(self).__module__,
|
||||
)
|
||||
if issue_tracker:
|
||||
translation_placeholders["issue_tracker"] = issue_tracker
|
||||
translation_key = "deprecated_climate_aux_url_custom"
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_climate_aux_{self.platform.platform_name}",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=self.platform.platform_name,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
self.__climate_reported_legacy_aux = True
|
||||
|
||||
@final
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
@@ -453,14 +395,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
|
||||
data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode
|
||||
|
||||
if ClimateEntityFeature.AUX_HEAT in supported_features:
|
||||
data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF
|
||||
if (
|
||||
self.__climate_reported_legacy_aux is False
|
||||
and "custom_components" in type(self).__module__
|
||||
):
|
||||
self._report_legacy_aux()
|
||||
|
||||
return data
|
||||
|
||||
@cached_property
|
||||
@@ -540,14 +474,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""
|
||||
return self._attr_preset_modes
|
||||
|
||||
@cached_property
|
||||
def is_aux_heat(self) -> bool | None:
|
||||
"""Return true if aux heater.
|
||||
|
||||
Requires ClimateEntityFeature.AUX_HEAT.
|
||||
"""
|
||||
return self._attr_is_aux_heat
|
||||
|
||||
@cached_property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting.
|
||||
@@ -732,22 +658,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Set new preset mode."""
|
||||
await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode)
|
||||
|
||||
def turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
await self.hass.async_add_executor_job(self.turn_aux_heat_on)
|
||||
|
||||
def turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
await self.hass.async_add_executor_job(self.turn_aux_heat_off)
|
||||
|
||||
def turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
raise NotImplementedError
|
||||
@@ -845,16 +755,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return self._attr_max_humidity
|
||||
|
||||
|
||||
async def async_service_aux_heat(
|
||||
entity: ClimateEntity, service_call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle aux heat service."""
|
||||
if service_call.data[ATTR_AUX_HEAT]:
|
||||
await entity.async_turn_aux_heat_on()
|
||||
else:
|
||||
await entity.async_turn_aux_heat_off()
|
||||
|
||||
|
||||
async def async_service_humidity_set(
|
||||
entity: ClimateEntity, service_call: ServiceCall
|
||||
) -> None:
|
||||
|
||||
@@ -96,7 +96,6 @@ class HVACAction(StrEnum):
|
||||
CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction]
|
||||
|
||||
|
||||
ATTR_AUX_HEAT = "aux_heat"
|
||||
ATTR_CURRENT_HUMIDITY = "current_humidity"
|
||||
ATTR_CURRENT_TEMPERATURE = "current_temperature"
|
||||
ATTR_FAN_MODES = "fan_modes"
|
||||
@@ -128,7 +127,6 @@ DOMAIN = "climate"
|
||||
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
SERVICE_SET_FAN_MODE = "set_fan_mode"
|
||||
SERVICE_SET_PRESET_MODE = "set_preset_mode"
|
||||
SERVICE_SET_HUMIDITY = "set_humidity"
|
||||
@@ -147,7 +145,6 @@ class ClimateEntityFeature(IntFlag):
|
||||
FAN_MODE = 8
|
||||
PRESET_MODE = 16
|
||||
SWING_MODE = 32
|
||||
AUX_HEAT = 64
|
||||
TURN_OFF = 128
|
||||
TURN_ON = 256
|
||||
SWING_HORIZONTAL_MODE = 512
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
# Describes the format for available climate services
|
||||
|
||||
set_aux_heat:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.AUX_HEAT
|
||||
fields:
|
||||
aux_heat:
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_preset_mode:
|
||||
target:
|
||||
entity:
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.helpers.significant_change import (
|
||||
)
|
||||
|
||||
from . import (
|
||||
ATTR_AUX_HEAT,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_FAN_MODE,
|
||||
@@ -27,7 +26,6 @@ from . import (
|
||||
)
|
||||
|
||||
SIGNIFICANT_ATTRIBUTES: set[str] = {
|
||||
ATTR_AUX_HEAT,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_FAN_MODE,
|
||||
@@ -67,7 +65,6 @@ def async_check_significant_change(
|
||||
|
||||
for attr_name in changed_attrs:
|
||||
if attr_name in [
|
||||
ATTR_AUX_HEAT,
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_HVAC_ACTION,
|
||||
ATTR_PRESET_MODE,
|
||||
|
||||
@@ -36,9 +36,6 @@
|
||||
"fan_only": "Fan only"
|
||||
},
|
||||
"state_attributes": {
|
||||
"aux_heat": {
|
||||
"name": "Aux heat"
|
||||
},
|
||||
"current_humidity": {
|
||||
"name": "Current humidity"
|
||||
},
|
||||
@@ -149,16 +146,6 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_aux_heat": {
|
||||
"name": "Turn on/off auxiliary heater",
|
||||
"description": "Turns auxiliary heater on/off.",
|
||||
"fields": {
|
||||
"aux_heat": {
|
||||
"name": "Auxiliary heating",
|
||||
"description": "New value of auxiliary heater."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_preset_mode": {
|
||||
"name": "Set preset mode",
|
||||
"description": "Sets preset mode.",
|
||||
@@ -267,16 +254,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_climate_aux_url_custom": {
|
||||
"title": "The {platform} custom integration is using deprecated climate auxiliary heater",
|
||||
"description": "The custom integration `{platform}` implements the `is_aux_heat` property or uses the auxiliary heater methods in a subclass of ClimateEntity.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue."
|
||||
},
|
||||
"deprecated_climate_aux_no_url": {
|
||||
"title": "[%key:component::climate::issues::deprecated_climate_aux_url_custom::title%]",
|
||||
"description": "The custom integration `{platform}` implements the `is_aux_heat` property or uses the auxiliary heater methods in a subclass of ClimateEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"not_valid_preset_mode": {
|
||||
"message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}."
|
||||
|
||||
@@ -43,7 +43,7 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
DOMAIN as CLOUD_DOMAIN,
|
||||
DOMAIN,
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
PREF_ENABLE_ALEXA,
|
||||
PREF_SHOULD_EXPOSE,
|
||||
@@ -55,7 +55,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
|
||||
CLOUD_ALEXA = f"{DOMAIN}.{ALEXA_DOMAIN}"
|
||||
|
||||
# Time to wait when entity preferences have changed before syncing it to
|
||||
# the cloud.
|
||||
|
||||
@@ -43,6 +43,7 @@ VALID_REPAIR_TRANSLATION_KEYS = {
|
||||
"no_subscription",
|
||||
"warn_bad_custom_domain_configuration",
|
||||
"reset_bad_custom_domain_configuration",
|
||||
"subscription_expired",
|
||||
}
|
||||
|
||||
|
||||
@@ -404,7 +405,12 @@ class CloudClient(Interface):
|
||||
) -> None:
|
||||
"""Create a repair issue."""
|
||||
if translation_key not in VALID_REPAIR_TRANSLATION_KEYS:
|
||||
raise ValueError(f"Invalid translation key {translation_key}")
|
||||
_LOGGER.error(
|
||||
"Invalid translation key %s for repair issue %s",
|
||||
translation_key,
|
||||
identifier,
|
||||
)
|
||||
return
|
||||
async_create_issue(
|
||||
hass=self._hass,
|
||||
domain=DOMAIN,
|
||||
|
||||
@@ -41,7 +41,7 @@ from .const import (
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
DEFAULT_DISABLE_2FA,
|
||||
DOMAIN as CLOUD_DOMAIN,
|
||||
DOMAIN,
|
||||
PREF_DISABLE_2FA,
|
||||
PREF_SHOULD_EXPOSE,
|
||||
)
|
||||
@@ -52,7 +52,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
|
||||
CLOUD_GOOGLE = f"{DOMAIN}.{GOOGLE_DOMAIN}"
|
||||
|
||||
|
||||
SUPPORTED_DOMAINS = {
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.100.0"],
|
||||
"requirements": ["hass-nabucasa==0.101.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
"reset_bad_custom_domain_configuration": {
|
||||
"title": "Custom domain ignored",
|
||||
"description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page."
|
||||
},
|
||||
"subscription_expired": {
|
||||
"title": "Subscription has expired",
|
||||
"description": "Your Home Assistant Cloud subscription has expired. Resubscribe at {account_url}."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -134,11 +135,9 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
self._attr_current_temperature = values[0] / 10
|
||||
|
||||
self._attr_hvac_action = None
|
||||
if _mode == ClimaComelitMode.OFF:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
if not _active:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
if _mode in API_STATUS:
|
||||
elif _mode in API_STATUS:
|
||||
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
|
||||
|
||||
self._attr_hvac_mode = None
|
||||
@@ -157,6 +156,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
self._update_attributes()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (
|
||||
@@ -173,6 +173,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
self._attr_target_temperature = target_temp
|
||||
self.async_write_ha_state()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set hvac mode."""
|
||||
|
||||
|
||||
@@ -28,20 +28,22 @@ DEFAULT_HOST = "192.168.1.252"
|
||||
DEFAULT_PIN = 111111
|
||||
|
||||
|
||||
def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema:
|
||||
"""Return user form schema."""
|
||||
user_input = user_input or {}
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int})
|
||||
STEP_RECONFIGURE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
@@ -87,13 +89,11 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=user_form_schema(user_input)
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=USER_SCHEMA)
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
@@ -108,21 +108,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=user_form_schema(user_input), errors=errors
|
||||
step_id="user", data_schema=USER_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth flow."""
|
||||
self.context["title_placeholders"] = {"host": entry_data[CONF_HOST]}
|
||||
self.context["title_placeholders"] = {CONF_HOST: entry_data[CONF_HOST]}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirm."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
entry_data = reauth_entry.data
|
||||
@@ -163,6 +163,42 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=STEP_RECONFIGURE
|
||||
)
|
||||
|
||||
updated_host = user_input[CONF_HOST]
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -7,13 +7,14 @@ from typing import Any, cast
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -68,16 +69,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
|
||||
if self._last_state in [None, "unknown"]:
|
||||
return None
|
||||
|
||||
if self.device_status != STATE_COVER.index("stopped"):
|
||||
return False
|
||||
|
||||
if self._last_action:
|
||||
return self._last_action == STATE_COVER.index("closing")
|
||||
|
||||
return self._last_state == CoverState.CLOSED
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
@@ -89,6 +84,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
"""Return if the cover is opening."""
|
||||
return self._current_action("opening")
|
||||
|
||||
@bridge_api_call
|
||||
async def _cover_set_state(self, action: int, state: int) -> None:
|
||||
"""Set desired cover state."""
|
||||
self._last_state = self.state
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -154,6 +155,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
self._update_attributes()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
if not self._attr_is_on:
|
||||
@@ -171,6 +173,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
self._attr_target_humidity = humidity
|
||||
self.async_write_ha_state()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
"""Set humidifier mode."""
|
||||
await self.coordinator.api.set_humidity_status(
|
||||
@@ -179,6 +182,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
self._attr_mode = mode
|
||||
self.async_write_ha_state()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on."""
|
||||
await self.coordinator.api.set_humidity_status(
|
||||
@@ -187,6 +191,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
@bridge_api_call
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off."""
|
||||
await self.coordinator.api.set_humidity_status(
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -39,6 +40,7 @@ class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
_attr_name = None
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
@bridge_api_call
|
||||
async def _light_set_state(self, state: int) -> None:
|
||||
"""Set desired light state."""
|
||||
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocomelit==0.12.0"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiocomelit==0.12.3"]
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: todo
|
||||
comment: wrap api calls in try block
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -55,10 +53,8 @@ rules:
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: review and complete missing ones
|
||||
docs-supported-functions: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
@@ -72,9 +68,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: PR in progress
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
|
||||
@@ -23,11 +23,24 @@
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"type": "The type of your Comelit device."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
@@ -76,6 +89,9 @@
|
||||
"cannot_authenticate": {
|
||||
"message": "Error authenticating"
|
||||
},
|
||||
"cannot_retrieve_data": {
|
||||
"message": "Error retrieving data: {error}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to update data: {error}"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -56,6 +57,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
if device.type == OTHER:
|
||||
self._attr_device_class = SwitchDeviceClass.OUTLET
|
||||
|
||||
@bridge_api_call
|
||||
async def _switch_set_state(self, state: int) -> None:
|
||||
"""Set desired switch state."""
|
||||
await self.coordinator.api.set_device_status(
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
"""Utils for Comelit."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
|
||||
|
||||
async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Return a new aiohttp session."""
|
||||
return aiohttp_client.async_create_clientsession(
|
||||
hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
|
||||
)
|
||||
|
||||
|
||||
def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Catch Bridge API call exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap all command methods."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except CannotConnect as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate:
|
||||
self.coordinator.last_update_success = False
|
||||
self.coordinator.config_entry.async_start_reauth(self.hass)
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
@@ -203,7 +203,11 @@ def async_get_agent_info(
|
||||
name = agent.name
|
||||
if not isinstance(name, str):
|
||||
name = agent.entity_id
|
||||
return AgentInfo(id=agent.entity_id, name=name)
|
||||
return AgentInfo(
|
||||
id=agent.entity_id,
|
||||
name=name,
|
||||
supports_streaming=agent.supports_streaming,
|
||||
)
|
||||
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class AgentManager:
|
||||
AgentInfo(
|
||||
id=agent_id,
|
||||
name=config_entry.title or config_entry.domain,
|
||||
supports_streaming=False,
|
||||
)
|
||||
)
|
||||
return agents
|
||||
|
||||
@@ -18,8 +18,14 @@ class ConversationEntity(RestoreEntity):
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = ConversationEntityFeature(0)
|
||||
_attr_supports_streaming = False
|
||||
__last_activity: str | None = None
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Return if the entity supports streaming responses."""
|
||||
return self._attr_supports_streaming
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
|
||||
@@ -16,6 +16,7 @@ class AgentInfo:
|
||||
|
||||
id: str
|
||||
name: str
|
||||
supports_streaming: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["debugpy==1.8.13"]
|
||||
"requirements": ["debugpy==1.8.14"]
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from ..const import (
|
||||
CONF_MASTER_GATEWAY,
|
||||
DOMAIN as DECONZ_DOMAIN,
|
||||
HASSIO_CONFIGURATION_URL,
|
||||
PLATFORMS,
|
||||
)
|
||||
from ..const import CONF_MASTER_GATEWAY, DOMAIN, HASSIO_CONFIGURATION_URL, PLATFORMS
|
||||
from .config import DeconzConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -193,7 +188,7 @@ class DeconzHub:
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
configuration_url=configuration_url,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)},
|
||||
identifiers={(DOMAIN, self.api.config.bridge_id)},
|
||||
manufacturer="Dresden Elektronik",
|
||||
model=self.api.config.model_id,
|
||||
name=self.api.config.name,
|
||||
|
||||
@@ -6,12 +6,16 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
SearchMedia,
|
||||
SearchMediaQuery,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -407,3 +411,18 @@ class DemoSearchPlayer(AbstractDemoPlayer):
|
||||
"""A Demo media player that supports searching."""
|
||||
|
||||
_attr_supported_features = SEARCH_PLAYER_SUPPORT
|
||||
|
||||
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
|
||||
"""Demo implementation of search media."""
|
||||
return SearchMedia(
|
||||
result=[
|
||||
BrowseMedia(
|
||||
title="Search result",
|
||||
media_class=MediaClass.MOVIE,
|
||||
media_content_type=MediaType.MOVIE,
|
||||
media_content_id="search_result_id",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/denonavr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.1.0"],
|
||||
"requirements": ["denonavr==1.1.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Denon",
|
||||
|
||||
@@ -8,11 +8,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
TriggerProtocol,
|
||||
)
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import (
|
||||
@@ -25,13 +21,28 @@ from .helpers import async_validate_device_automation_config
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol):
|
||||
class DeviceAutomationTriggerProtocol(Protocol):
|
||||
"""Define the format of device_trigger modules.
|
||||
|
||||
Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config
|
||||
from TriggerProtocol.
|
||||
Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config.
|
||||
"""
|
||||
|
||||
TRIGGER_SCHEMA: vol.Schema
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
|
||||
async def async_attach_trigger(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: TriggerActionType,
|
||||
trigger_info: TriggerInfo,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
async def async_get_trigger_capabilities(
|
||||
self, hass: HomeAssistant, config: ConfigType
|
||||
) -> dict[str, vol.Schema]:
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache, partial
|
||||
from ipaddress import IPv4Address
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
@@ -22,6 +23,7 @@ from aiodiscover.discovery import (
|
||||
from cached_ipaddress import cached_ip_addresses
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import network
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IP,
|
||||
@@ -421,9 +423,33 @@ class DHCPWatcher(WatcherBase):
|
||||
response.ip_address, response.hostname, response.mac_address
|
||||
)
|
||||
|
||||
async def async_get_adapter_indexes(self) -> list[int] | None:
|
||||
"""Get the adapter indexes."""
|
||||
adapters = await network.async_get_adapters(self.hass)
|
||||
if network.async_only_default_interface_enabled(adapters):
|
||||
return None
|
||||
return [
|
||||
adapter["index"]
|
||||
for adapter in adapters
|
||||
if (
|
||||
adapter["enabled"]
|
||||
and adapter["index"] is not None
|
||||
and adapter["ipv4"]
|
||||
and (
|
||||
addresses := [IPv4Address(ip["address"]) for ip in adapter["ipv4"]]
|
||||
)
|
||||
and any(
|
||||
ip for ip in addresses if not ip.is_loopback and not ip.is_global
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start watching for dhcp packets."""
|
||||
self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request)
|
||||
self._unsub = await aiodhcpwatcher.async_start(
|
||||
self._async_process_dhcp_request,
|
||||
await self.async_get_adapter_indexes(),
|
||||
)
|
||||
|
||||
|
||||
class RediscoveryWatcher(WatcherBase):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "dhcp",
|
||||
"name": "DHCP Discovery",
|
||||
"codeowners": ["@bdraco"],
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dhcp",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
@@ -14,7 +15,7 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.1.1",
|
||||
"aiodhcpwatcher==1.2.0",
|
||||
"aiodiscover==2.7.0",
|
||||
"cached-ipaddress==0.10.0"
|
||||
]
|
||||
|
||||
@@ -25,37 +25,28 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Dlib Face detection platform."""
|
||||
source: list[dict[str, str]] = config[CONF_SOURCE]
|
||||
add_entities(
|
||||
DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME))
|
||||
for camera in config[CONF_SOURCE]
|
||||
for camera in source
|
||||
)
|
||||
|
||||
|
||||
class DlibFaceDetectEntity(ImageProcessingFaceEntity):
|
||||
"""Dlib Face API entity for identify."""
|
||||
|
||||
def __init__(self, camera_entity, name=None):
|
||||
def __init__(self, camera_entity: str, name: str | None) -> None:
|
||||
"""Initialize Dlib face entity."""
|
||||
super().__init__()
|
||||
|
||||
self._camera = camera_entity
|
||||
self._attr_camera_entity = camera_entity
|
||||
|
||||
if name:
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
else:
|
||||
self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
|
||||
self._attr_name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
|
||||
|
||||
@property
|
||||
def camera_entity(self):
|
||||
"""Return camera entity id from process pictures."""
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
def process_image(self, image):
|
||||
def process_image(self, image: bytes) -> None:
|
||||
"""Process image."""
|
||||
|
||||
fak_file = io.BytesIO(image)
|
||||
|
||||
@@ -11,6 +11,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.image_processing import (
|
||||
CONF_CONFIDENCE,
|
||||
PLATFORM_SCHEMA as IMAGE_PROCESSING_PLATFORM_SCHEMA,
|
||||
FaceInformation,
|
||||
ImageProcessingFaceEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
|
||||
@@ -38,31 +39,40 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Dlib Face detection platform."""
|
||||
confidence: float = config[CONF_CONFIDENCE]
|
||||
faces: dict[str, str] = config[CONF_FACES]
|
||||
source: list[dict[str, str]] = config[CONF_SOURCE]
|
||||
add_entities(
|
||||
DlibFaceIdentifyEntity(
|
||||
camera[CONF_ENTITY_ID],
|
||||
config[CONF_FACES],
|
||||
faces,
|
||||
camera.get(CONF_NAME),
|
||||
config[CONF_CONFIDENCE],
|
||||
confidence,
|
||||
)
|
||||
for camera in config[CONF_SOURCE]
|
||||
for camera in source
|
||||
)
|
||||
|
||||
|
||||
class DlibFaceIdentifyEntity(ImageProcessingFaceEntity):
|
||||
"""Dlib Face API entity for identify."""
|
||||
|
||||
def __init__(self, camera_entity, faces, name, tolerance):
|
||||
def __init__(
|
||||
self,
|
||||
camera_entity: str,
|
||||
faces: dict[str, str],
|
||||
name: str | None,
|
||||
tolerance: float,
|
||||
) -> None:
|
||||
"""Initialize Dlib face identify entry."""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self._camera = camera_entity
|
||||
self._attr_camera_entity = camera_entity
|
||||
|
||||
if name:
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
else:
|
||||
self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
|
||||
self._attr_name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
|
||||
|
||||
self._faces = {}
|
||||
for face_name, face_file in faces.items():
|
||||
@@ -74,17 +84,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity):
|
||||
|
||||
self._tolerance = tolerance
|
||||
|
||||
@property
|
||||
def camera_entity(self):
|
||||
"""Return camera entity id from process pictures."""
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
def process_image(self, image):
|
||||
def process_image(self, image: bytes) -> None:
|
||||
"""Process image."""
|
||||
|
||||
fak_file = io.BytesIO(image)
|
||||
@@ -94,7 +94,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity):
|
||||
image = face_recognition.load_image_file(fak_file)
|
||||
unknowns = face_recognition.face_encodings(image)
|
||||
|
||||
found = []
|
||||
found: list[FaceInformation] = []
|
||||
for unknown_face in unknowns:
|
||||
for name, face in self._faces.items():
|
||||
result = face_recognition.compare_faces(
|
||||
|
||||
@@ -6,6 +6,7 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
from pydoods import PyDOODS
|
||||
@@ -88,10 +89,11 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Doods client."""
|
||||
url = config[CONF_URL]
|
||||
auth_key = config[CONF_AUTH_KEY]
|
||||
detector_name = config[CONF_DETECTOR]
|
||||
timeout = config[CONF_TIMEOUT]
|
||||
url: str = config[CONF_URL]
|
||||
auth_key: str = config[CONF_AUTH_KEY]
|
||||
detector_name: str = config[CONF_DETECTOR]
|
||||
source: list[dict[str, str]] = config[CONF_SOURCE]
|
||||
timeout: int = config[CONF_TIMEOUT]
|
||||
|
||||
doods = PyDOODS(url, auth_key, timeout)
|
||||
response = doods.get_detectors()
|
||||
@@ -113,31 +115,35 @@ def setup_platform(
|
||||
|
||||
add_entities(
|
||||
Doods(
|
||||
hass,
|
||||
camera[CONF_ENTITY_ID],
|
||||
camera.get(CONF_NAME),
|
||||
doods,
|
||||
detector,
|
||||
config,
|
||||
)
|
||||
for camera in config[CONF_SOURCE]
|
||||
for camera in source
|
||||
)
|
||||
|
||||
|
||||
class Doods(ImageProcessingEntity):
|
||||
"""Doods image processing service client."""
|
||||
|
||||
def __init__(self, hass, camera_entity, name, doods, detector, config):
|
||||
def __init__(
|
||||
self,
|
||||
camera_entity: str,
|
||||
name: str | None,
|
||||
doods: PyDOODS,
|
||||
detector: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the DOODS entity."""
|
||||
self.hass = hass
|
||||
self._camera_entity = camera_entity
|
||||
self._attr_camera_entity = camera_entity
|
||||
if name:
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
else:
|
||||
name = split_entity_id(camera_entity)[1]
|
||||
self._name = f"Doods {name}"
|
||||
self._attr_name = f"Doods {split_entity_id(camera_entity)[1]}"
|
||||
self._doods = doods
|
||||
self._file_out = config[CONF_FILE_OUT]
|
||||
self._file_out: list[template.Template] = config[CONF_FILE_OUT]
|
||||
self._detector_name = detector["name"]
|
||||
|
||||
# detector config and aspect ratio
|
||||
@@ -150,16 +156,16 @@ class Doods(ImageProcessingEntity):
|
||||
self._aspect = self._width / self._height
|
||||
|
||||
# the base confidence
|
||||
dconfig = {}
|
||||
confidence = config[CONF_CONFIDENCE]
|
||||
dconfig: dict[str, float] = {}
|
||||
confidence: float = config[CONF_CONFIDENCE]
|
||||
|
||||
# handle labels and specific detection areas
|
||||
labels = config[CONF_LABELS]
|
||||
labels: list[str | dict[str, Any]] = config[CONF_LABELS]
|
||||
self._label_areas = {}
|
||||
self._label_covers = {}
|
||||
for label in labels:
|
||||
if isinstance(label, dict):
|
||||
label_name = label[CONF_NAME]
|
||||
label_name: str = label[CONF_NAME]
|
||||
if label_name not in detector["labels"] and label_name != "*":
|
||||
_LOGGER.warning("Detector does not support label %s", label_name)
|
||||
continue
|
||||
@@ -207,28 +213,18 @@ class Doods(ImageProcessingEntity):
|
||||
self._covers = area_config[CONF_COVERS]
|
||||
|
||||
self._dconfig = dconfig
|
||||
self._matches = {}
|
||||
self._matches: dict[str, list[dict[str, Any]]] = {}
|
||||
self._total_matches = 0
|
||||
self._last_image = None
|
||||
self._process_time = 0
|
||||
self._process_time = 0.0
|
||||
|
||||
@property
|
||||
def camera_entity(self):
|
||||
"""Return camera entity id from process pictures."""
|
||||
return self._camera_entity
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the image processor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
def state(self) -> int:
|
||||
"""Return the state of the entity."""
|
||||
return self._total_matches
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return device specific state attributes."""
|
||||
return {
|
||||
ATTR_MATCHES: self._matches,
|
||||
@@ -281,7 +277,7 @@ class Doods(ImageProcessingEntity):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
|
||||
def process_image(self, image):
|
||||
def process_image(self, image: bytes) -> None:
|
||||
"""Process the image."""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
|
||||
@@ -312,7 +308,7 @@ class Doods(ImageProcessingEntity):
|
||||
time.monotonic() - start,
|
||||
)
|
||||
|
||||
matches = {}
|
||||
matches: dict[str, list[dict[str, Any]]] = {}
|
||||
total_matches = 0
|
||||
|
||||
if not response or "error" in response:
|
||||
@@ -382,9 +378,7 @@ class Doods(ImageProcessingEntity):
|
||||
paths = []
|
||||
for path_template in self._file_out:
|
||||
if isinstance(path_template, template.Template):
|
||||
paths.append(
|
||||
path_template.render(camera_entity=self._camera_entity)
|
||||
)
|
||||
paths.append(path_template.render(camera_entity=self.camera_entity))
|
||||
else:
|
||||
paths.append(path_template)
|
||||
self._save_image(image, matches, paths)
|
||||
|
||||
@@ -2,32 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
ATTR_FILENAME,
|
||||
ATTR_OVERWRITE,
|
||||
ATTR_SUBDIR,
|
||||
ATTR_URL,
|
||||
CONF_DOWNLOAD_DIR,
|
||||
DOMAIN,
|
||||
DOWNLOAD_COMPLETED_EVENT,
|
||||
DOWNLOAD_FAILED_EVENT,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
)
|
||||
from .const import _LOGGER, CONF_DOWNLOAD_DIR
|
||||
from .services import register_services
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -44,127 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
def download_file(service: ServiceCall) -> None:
|
||||
"""Start thread to download file specified in the URL."""
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
_LOGGER.warning(
|
||||
"Downloading '%s' failed, status_code=%d", url, req.status_code
|
||||
)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
else:
|
||||
if filename is None and "content-disposition" in req.headers:
|
||||
match = re.findall(
|
||||
r"filename=(\S+)", req.headers["content-disposition"]
|
||||
)
|
||||
|
||||
if match:
|
||||
filename = match[0].strip("'\" ")
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(url).strip()
|
||||
|
||||
if not filename:
|
||||
filename = "ha_download"
|
||||
|
||||
# Check the filename
|
||||
raise_if_invalid_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
subdir_path = os.path.join(download_path, subdir)
|
||||
|
||||
# Ensure subdir exist
|
||||
os.makedirs(subdir_path, exist_ok=True)
|
||||
|
||||
final_path = os.path.join(subdir_path, filename)
|
||||
|
||||
else:
|
||||
final_path = os.path.join(download_path, filename)
|
||||
|
||||
path, ext = os.path.splitext(final_path)
|
||||
|
||||
# If file exist append a number.
|
||||
# We test filename, filename_2..
|
||||
if not overwrite:
|
||||
tries = 1
|
||||
final_path = path + ext
|
||||
while os.path.isfile(final_path):
|
||||
tries += 1
|
||||
|
||||
final_path = f"{path}_{tries}.{ext}"
|
||||
|
||||
_LOGGER.debug("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, "wb") as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
_LOGGER.debug("Downloading of %s done", url)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.exception("ConnectionError occurred for %s", url)
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
except ValueError:
|
||||
_LOGGER.exception("Invalid value")
|
||||
hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
download_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
159
homeassistant/components/downloader/services.py
Normal file
159
homeassistant/components/downloader/services.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Support for functionality to download files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
ATTR_FILENAME,
|
||||
ATTR_OVERWRITE,
|
||||
ATTR_SUBDIR,
|
||||
ATTR_URL,
|
||||
CONF_DOWNLOAD_DIR,
|
||||
DOMAIN,
|
||||
DOWNLOAD_COMPLETED_EVENT,
|
||||
DOWNLOAD_FAILED_EVENT,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
)
|
||||
|
||||
|
||||
def download_file(service: ServiceCall) -> None:
|
||||
"""Start thread to download file specified in the URL."""
|
||||
|
||||
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
download_path = entry.data[CONF_DOWNLOAD_DIR]
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
_LOGGER.warning(
|
||||
"Downloading '%s' failed, status_code=%d", url, req.status_code
|
||||
)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
else:
|
||||
if filename is None and "content-disposition" in req.headers:
|
||||
match = re.findall(
|
||||
r"filename=(\S+)", req.headers["content-disposition"]
|
||||
)
|
||||
|
||||
if match:
|
||||
filename = match[0].strip("'\" ")
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(url).strip()
|
||||
|
||||
if not filename:
|
||||
filename = "ha_download"
|
||||
|
||||
# Check the filename
|
||||
raise_if_invalid_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
subdir_path = os.path.join(download_path, subdir)
|
||||
|
||||
# Ensure subdir exist
|
||||
os.makedirs(subdir_path, exist_ok=True)
|
||||
|
||||
final_path = os.path.join(subdir_path, filename)
|
||||
|
||||
else:
|
||||
final_path = os.path.join(download_path, filename)
|
||||
|
||||
path, ext = os.path.splitext(final_path)
|
||||
|
||||
# If file exist append a number.
|
||||
# We test filename, filename_2..
|
||||
if not overwrite:
|
||||
tries = 1
|
||||
final_path = path + ext
|
||||
while os.path.isfile(final_path):
|
||||
tries += 1
|
||||
|
||||
final_path = f"{path}_{tries}.{ext}"
|
||||
|
||||
_LOGGER.debug("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, "wb") as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
_LOGGER.debug("Downloading of %s done", url)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.exception("ConnectionError occurred for %s", url)
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
except ValueError:
|
||||
_LOGGER.exception("Invalid value")
|
||||
service.hass.bus.fire(
|
||||
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
|
||||
{"url": url, "filename": filename},
|
||||
)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
os.remove(final_path)
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
"""Register the services for the downloader component."""
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DOWNLOAD_FILE,
|
||||
download_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -572,7 +572,7 @@ def device_class_and_uom(
|
||||
) -> tuple[SensorDeviceClass | None, str | None]:
|
||||
"""Get native unit of measurement from telegram,."""
|
||||
dsmr_object = getattr(data, entity_description.obis_reference)
|
||||
uom: str | None = getattr(dsmr_object, "unit") or None
|
||||
uom: str | None = dsmr_object.unit or None
|
||||
with suppress(ValueError):
|
||||
if entity_description.device_class == SensorDeviceClass.GAS and (
|
||||
enery_uom := UnitOfEnergy(str(uom))
|
||||
|
||||
@@ -53,7 +53,6 @@ SUPPORT_FLAGS_THERMOSTAT = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.AUX_HEAT
|
||||
)
|
||||
|
||||
|
||||
@@ -148,11 +147,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
if target_temp_low or target_temp_high:
|
||||
self._econet.set_set_point(None, target_temp_high, target_temp_low)
|
||||
|
||||
@property
|
||||
def is_aux_heat(self) -> bool:
|
||||
"""Return true if aux heater."""
|
||||
return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool, mode.
|
||||
@@ -211,12 +205,12 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
return self._econet.set_point_limits[0]
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
return self._econet.set_point_limits[1]
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
async_add_entities(
|
||||
get_supported_entitites(
|
||||
get_supported_entities(
|
||||
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,13 +16,13 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
|
||||
from .const import SUPPORTED_LIFESPANS
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
|
||||
key=f"station_action_{action.name.lower()}",
|
||||
translation_key=f"station_action_{action.name.lower()}",
|
||||
)
|
||||
for action in SUPPORTED_STATION_ACTIONS
|
||||
for action in StationAction
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from .entity import (
|
||||
EcovacsEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -87,7 +87,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_name_key, get_supported_entitites
|
||||
from .util import get_name_key, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -59,7 +59,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities = get_supported_entitites(
|
||||
entities = get_supported_entities(
|
||||
controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -6,7 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan
|
||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import (
|
||||
BatteryEvent,
|
||||
ErrorEvent,
|
||||
@@ -34,7 +35,7 @@ from homeassistant.const import (
|
||||
UnitOfArea,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
@@ -47,7 +48,7 @@ from .entity import (
|
||||
EcovacsLegacyEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_name_key, get_options, get_supported_entitites
|
||||
from .util import get_name_key, get_options, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -59,6 +60,15 @@ class EcovacsSensorEntityDescription(
|
||||
"""Ecovacs sensor entity description."""
|
||||
|
||||
value_fn: Callable[[EventT], StateType]
|
||||
native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None
|
||||
|
||||
|
||||
@callback
|
||||
def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None:
|
||||
"""Get the area native unit of measurement based on device type."""
|
||||
if device_type is DeviceType.MOWER:
|
||||
return UnitOfArea.SQUARE_CENTIMETERS
|
||||
return UnitOfArea.SQUARE_METERS
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
@@ -68,7 +78,9 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
capability_fn=lambda caps: caps.stats.clean,
|
||||
value_fn=lambda e: e.area,
|
||||
translation_key="stats_area",
|
||||
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
device_class=SensorDeviceClass.AREA,
|
||||
native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
|
||||
suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
),
|
||||
EcovacsSensorEntityDescription[StatsEvent](
|
||||
key="stats_time",
|
||||
@@ -85,6 +97,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
|
||||
value_fn=lambda e: e.area,
|
||||
key="total_stats_area",
|
||||
translation_key="total_stats_area",
|
||||
device_class=SensorDeviceClass.AREA,
|
||||
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -197,7 +210,7 @@ async def async_setup_entry(
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsSensor, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
@@ -249,6 +262,27 @@ class EcovacsSensor(
|
||||
|
||||
entity_description: EcovacsSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilityEvent,
|
||||
entity_description: EcovacsSensorEntityDescription,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, entity_description, **kwargs)
|
||||
if (
|
||||
entity_description.native_unit_of_measurement_fn
|
||||
and (
|
||||
native_unit_of_measurement
|
||||
:= entity_description.native_unit_of_measurement_fn(
|
||||
device.capabilities.device_type
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self._attr_native_unit_of_measurement = native_unit_of_measurement
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -17,7 +17,7 @@ from .entity import (
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
)
|
||||
from .util import get_supported_entitites
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
@@ -109,7 +109,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
entities: list[EcovacsEntity] = get_supported_entitites(
|
||||
entities: list[EcovacsEntity] = get_supported_entities(
|
||||
controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
if entities:
|
||||
|
||||
@@ -32,7 +32,7 @@ def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str:
|
||||
)
|
||||
|
||||
|
||||
def get_supported_entitites(
|
||||
def get_supported_entities(
|
||||
controller: EcovacsController,
|
||||
entity_class: type[EcovacsDescriptionEntity],
|
||||
descriptions: tuple[EcovacsCapabilityEntityDescription, ...],
|
||||
|
||||
@@ -13,6 +13,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
},
|
||||
"night_temperature_offset": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"system_led": {
|
||||
"default": "mdi:led-on",
|
||||
"state": {
|
||||
"0": "mdi:led-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["eheimdigital==1.1.0"],
|
||||
"requirements": ["eheimdigital==1.2.0"],
|
||||
"zeroconf": [
|
||||
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -109,6 +109,20 @@ HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ..
|
||||
),
|
||||
)
|
||||
|
||||
GENERAL_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalDevice], ...] = (
|
||||
EheimDigitalNumberDescription[EheimDigitalDevice](
|
||||
key="system_led",
|
||||
translation_key="system_led",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=PRECISION_WHOLE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device: device.sys_led,
|
||||
set_value_fn=lambda device, value: device.set_sys_led(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -138,6 +152,10 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in HEATER_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description)
|
||||
for description in GENERAL_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
102
homeassistant/components/eheimdigital/select.py
Normal file
102
homeassistant/components/eheimdigital/select.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""EHEIM Digital select entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import FilterMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital select entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT_co], str | None]
|
||||
set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]]
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
EheimDigitalSelectDescription[EheimDigitalClassicVario], ...
|
||||
] = (
|
||||
EheimDigitalSelectDescription[EheimDigitalClassicVario](
|
||||
key="filter_mode",
|
||||
translation_key="filter_mode",
|
||||
value_fn=(
|
||||
lambda device: device.filter_mode.name.lower()
|
||||
if device.filter_mode is not None
|
||||
else None
|
||||
),
|
||||
set_value_fn=(
|
||||
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
|
||||
),
|
||||
options=[name.lower() for name in FilterMode.__members__],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so select entities can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the number entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalSelect[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
EheimDigitalSelect[EheimDigitalClassicVario](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in CLASSICVARIO_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSelect(
|
||||
EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent an EHEIM Digital select entity."""
|
||||
|
||||
entity_description: EheimDigitalSelectDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalSelectDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital select entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@override
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
return await self.entity_description.set_value_fn(self._device, option)
|
||||
|
||||
@override
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_current_option = self.entity_description.value_fn(self._device)
|
||||
@@ -62,6 +62,19 @@
|
||||
},
|
||||
"night_temperature_offset": {
|
||||
"name": "Night temperature offset"
|
||||
},
|
||||
"system_led": {
|
||||
"name": "System LED brightness"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"filter_mode": {
|
||||
"name": "Filter mode",
|
||||
"state": {
|
||||
"manual": "Manual",
|
||||
"pulse": "Pulse",
|
||||
"bio": "Bio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
|
||||
@@ -20,10 +20,8 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import PRECISION_WHOLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import ElkEntity, create_elk_entities
|
||||
|
||||
SUPPORT_HVAC = [
|
||||
@@ -78,7 +76,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
_attr_precision = PRECISION_WHOLE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.AUX_HEAT
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
@@ -128,11 +125,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
"""Return the current humidity."""
|
||||
return self._element.humidity
|
||||
|
||||
@property
|
||||
def is_aux_heat(self) -> bool:
|
||||
"""Return if aux heater is on."""
|
||||
return self._element.mode == ThermostatMode.EMERGENCY_HEAT
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
@@ -151,34 +143,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
|
||||
thermostat_mode, fan_mode = HASS_TO_ELK_HVAC_MODES[hvac_mode]
|
||||
self._elk_set(thermostat_mode, fan_mode)
|
||||
|
||||
async def async_turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
translation_key="migrate_aux_heat",
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
self._elk_set(ThermostatMode.EMERGENCY_HEAT, None)
|
||||
|
||||
async def async_turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
breaks_in_ha_version="2025.4.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
translation_key="migrate_aux_heat",
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
self._elk_set(ThermostatMode.HEAT, None)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
thermostat_mode, elk_fan_mode = HASS_TO_ELK_FAN_MODES[fan_mode]
|
||||
|
||||
@@ -189,18 +189,5 @@
|
||||
"name": "Sensor zone trigger",
|
||||
"description": "Triggers zone."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"migrate_aux_heat": {
|
||||
"title": "Migration of Elk-M1 set_aux_heat action",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select **Submit** to fix this issue.",
|
||||
"title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.7"]
|
||||
"requirements": ["sense-energy==0.13.8"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user