mirror of
https://github.com/home-assistant/core.git
synced 2026-01-06 23:57:17 +01:00
Compare commits
505 Commits
statistics
...
2024.8.0b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782ff12e6e | ||
|
|
af6f78a784 | ||
|
|
db32460f3b | ||
|
|
270990fe39 | ||
|
|
a10fed9d72 | ||
|
|
cc5699bf08 | ||
|
|
ad674a1c2b | ||
|
|
b0269faae4 | ||
|
|
1143efedc5 | ||
|
|
9e75b63925 | ||
|
|
940327dccf | ||
|
|
0270026f7c | ||
|
|
b636096ac3 | ||
|
|
a243ed5b23 | ||
|
|
3cf3780587 | ||
|
|
3d0a0cf376 | ||
|
|
7aae9d9ad3 | ||
|
|
870bb7efd4 | ||
|
|
35a6679ae9 | ||
|
|
a09d0117b1 | ||
|
|
e9fe98f7f9 | ||
|
|
5b2e188b52 | ||
|
|
c1953e938d | ||
|
|
77bcbbcf53 | ||
|
|
97587fae08 | ||
|
|
01b54fe1a9 | ||
|
|
f796950493 | ||
|
|
495fd946bc | ||
|
|
6af1e25d7e | ||
|
|
6d47a4d7e4 | ||
|
|
fd5533d719 | ||
|
|
d530137bec | ||
|
|
4f722e864c | ||
|
|
62d38e786d | ||
|
|
859874487e | ||
|
|
b16bf29819 | ||
|
|
6b10dbb38c | ||
|
|
ea20c4b375 | ||
|
|
0427aeccb0 | ||
|
|
4898ba932d | ||
|
|
35a3d2306c | ||
|
|
cdb378066c | ||
|
|
85700fd80f | ||
|
|
73a2ad7304 | ||
|
|
f6c4b6b045 | ||
|
|
0b4d921762 | ||
|
|
c8a0e5228d | ||
|
|
832bac8c63 | ||
|
|
eccce7017f | ||
|
|
fdb1baadbe | ||
|
|
7623ee49e4 | ||
|
|
fa241dcd04 | ||
|
|
bee77041e8 | ||
|
|
50b7eb44d1 | ||
|
|
7b1bf82e3c | ||
|
|
fe82e7f24d | ||
|
|
433c1a57e7 | ||
|
|
b36059fc64 | ||
|
|
13c9d69440 | ||
|
|
9c7134a865 | ||
|
|
d7cc2a7e9a | ||
|
|
f9276e28b0 | ||
|
|
15ad6db1a7 | ||
|
|
c1043ada22 | ||
|
|
d141122008 | ||
|
|
abeba39842 | ||
|
|
bb597a908d | ||
|
|
dcae2f35ce | ||
|
|
b06a5af069 | ||
|
|
a624ada8d6 | ||
|
|
d87366b1e7 | ||
|
|
5ce8a2d974 | ||
|
|
a42615add0 | ||
|
|
ecbff61332 | ||
|
|
e9bfe82582 | ||
|
|
55abe68a5f | ||
|
|
acf523b5fb | ||
|
|
0216455137 | ||
|
|
cb37ae6608 | ||
|
|
3b462906d9 | ||
|
|
dfb4e9c159 | ||
|
|
6a6814af61 | ||
|
|
1a7085b068 | ||
|
|
804d7aa4c0 | ||
|
|
1b1d86409c | ||
|
|
2520fcd284 | ||
|
|
18afe07c16 | ||
|
|
6baee603a5 | ||
|
|
5fefa606b6 | ||
|
|
7bc2381a45 | ||
|
|
2910369647 | ||
|
|
17f34b452e | ||
|
|
220f686078 | ||
|
|
d878d744e7 | ||
|
|
b31263b747 | ||
|
|
4a4209647e | ||
|
|
9860109db9 | ||
|
|
b8ac86939b | ||
|
|
9023d80d1b | ||
|
|
c702ffa7dd | ||
|
|
be8186126e | ||
|
|
4fda025106 | ||
|
|
c7f863a141 | ||
|
|
4aacec2de7 | ||
|
|
a6aae4e857 | ||
|
|
dde97a02f0 | ||
|
|
d393317eb2 | ||
|
|
2f3f124aa1 | ||
|
|
79a741486c | ||
|
|
c0fe65fa60 | ||
|
|
8de0e4ca7c | ||
|
|
7c179c33b5 | ||
|
|
177690bcb3 | ||
|
|
a23b3f84f0 | ||
|
|
d5388452d4 | ||
|
|
8a4206da99 | ||
|
|
f1084a57df | ||
|
|
0189a05297 | ||
|
|
7276b4b3ad | ||
|
|
93bcd413a7 | ||
|
|
bc25657f0a | ||
|
|
9db42beade | ||
|
|
cc1a6d60c0 | ||
|
|
a913587eb6 | ||
|
|
ae9e8ca419 | ||
|
|
69a8c5dc9f | ||
|
|
3f091470fd | ||
|
|
c888908cc8 | ||
|
|
172e2125f6 | ||
|
|
69f54656c4 | ||
|
|
c359d4a419 | ||
|
|
a1b8545568 | ||
|
|
8c0d9a1320 | ||
|
|
f764705629 | ||
|
|
3df78043c0 | ||
|
|
97de1c2b66 | ||
|
|
f7f0f49015 | ||
|
|
a35fa0e95a | ||
|
|
cddb3bb668 | ||
|
|
e64e3c2778 | ||
|
|
5e1cca1c58 | ||
|
|
2f181cbe41 | ||
|
|
e706ff0564 | ||
|
|
8d0e998e54 | ||
|
|
4f5eab4646 | ||
|
|
8b96c7873f | ||
|
|
f14471112d | ||
|
|
7c7b408df1 | ||
|
|
3bf00822b0 | ||
|
|
bf3a2cf393 | ||
|
|
c8dccec956 | ||
|
|
ed9c4e0c0d | ||
|
|
cd552ceb2b | ||
|
|
c4398efbbb | ||
|
|
8b1a527602 | ||
|
|
01f41a597e | ||
|
|
c32f1efad0 | ||
|
|
47c96c52b1 | ||
|
|
dbdb148e12 | ||
|
|
6a45124878 | ||
|
|
02d4d1a75b | ||
|
|
233c04a469 | ||
|
|
8b4f607806 | ||
|
|
68f06e63e2 | ||
|
|
67ed8b207a | ||
|
|
222011fc5c | ||
|
|
f6f7459c36 | ||
|
|
718bc61c88 | ||
|
|
015a1a6ebc | ||
|
|
e0a1aaa1b9 | ||
|
|
7f4dabf546 | ||
|
|
beb2ef121e | ||
|
|
5a04d982d9 | ||
|
|
35bfd0b88f | ||
|
|
9351f300b0 | ||
|
|
0d678120e4 | ||
|
|
5766ea9541 | ||
|
|
6d8bc84db3 | ||
|
|
823910b69e | ||
|
|
aa801d9cc6 | ||
|
|
067acce4de | ||
|
|
6999c6b0cf | ||
|
|
da18aae2d8 | ||
|
|
6362ca1052 | ||
|
|
022e1b0c02 | ||
|
|
94c0b9fc06 | ||
|
|
5eff4f9816 | ||
|
|
fb229fcae8 | ||
|
|
50b35ac4bc | ||
|
|
ea727546d6 | ||
|
|
18a7d15d14 | ||
|
|
be24475cee | ||
|
|
8066c7dec6 | ||
|
|
896cd27bea | ||
|
|
1ffde403f0 | ||
|
|
b69b927795 | ||
|
|
6840f27bc6 | ||
|
|
4a34855a92 | ||
|
|
b3f7f379df | ||
|
|
4994e46ad0 | ||
|
|
1382f7a3dc | ||
|
|
b973455037 | ||
|
|
a5136a1021 | ||
|
|
d9e996def5 | ||
|
|
224228e448 | ||
|
|
c8372a3aa5 | ||
|
|
2135691b90 | ||
|
|
ea508b2629 | ||
|
|
09cd79772f | ||
|
|
7b5db6521c | ||
|
|
27eba3cd46 | ||
|
|
41c7414d97 | ||
|
|
fd7c92879c | ||
|
|
e7971f5a67 | ||
|
|
72f9d85bbe | ||
|
|
956cc6a85c | ||
|
|
015c50bbdb | ||
|
|
b6f0893c33 | ||
|
|
7c92287f97 | ||
|
|
53a59412bb | ||
|
|
d78acd480a | ||
|
|
17930a6d66 | ||
|
|
d825ac346e | ||
|
|
fa53055485 | ||
|
|
b4cba01870 | ||
|
|
70e368a57e | ||
|
|
004eccec89 | ||
|
|
36c01042c1 | ||
|
|
bd3f0da385 | ||
|
|
9450744b3b | ||
|
|
1c03c83c0a | ||
|
|
fdab23c3f9 | ||
|
|
7b08e625b4 | ||
|
|
2102a104d2 | ||
|
|
3e1aee4cbc | ||
|
|
b5b01d97f1 | ||
|
|
02581bbf02 | ||
|
|
4ac85829c8 | ||
|
|
ad50136dbd | ||
|
|
20c4f84a4e | ||
|
|
bf38db0035 | ||
|
|
b8c363a82c | ||
|
|
9393dcddb7 | ||
|
|
5b434ee336 | ||
|
|
6ba6334512 | ||
|
|
8de7a2e3c7 | ||
|
|
197ac8b950 | ||
|
|
1958a149c3 | ||
|
|
7bbbda8d2b | ||
|
|
5d87a74c3c | ||
|
|
1f488b00f8 | ||
|
|
570725293c | ||
|
|
732b9e47c8 | ||
|
|
ea75c8864f | ||
|
|
9514a38320 | ||
|
|
d94e79d57a | ||
|
|
02c592d6af | ||
|
|
d51d584aed | ||
|
|
e5fd9819da | ||
|
|
00c3b0d888 | ||
|
|
aa44c54a19 | ||
|
|
9940d0281b | ||
|
|
586a0b12ab | ||
|
|
75f0384a15 | ||
|
|
56f51d3e35 | ||
|
|
7135a919e3 | ||
|
|
9d6bd359c4 | ||
|
|
f739644735 | ||
|
|
b63bc72450 | ||
|
|
683069cb98 | ||
|
|
cf20e67f1f | ||
|
|
74d10b9824 | ||
|
|
61d4bc1430 | ||
|
|
07c7bb8b2a | ||
|
|
9ce7779bde | ||
|
|
9e10126505 | ||
|
|
cfef72ae57 | ||
|
|
86bfc7ada8 | ||
|
|
0de75aeee1 | ||
|
|
075550b7ba | ||
|
|
e5bb1b2cc6 | ||
|
|
85aca4f095 | ||
|
|
745eea9a29 | ||
|
|
d586e7df33 | ||
|
|
6d4711ce43 | ||
|
|
8a84addc54 | ||
|
|
1879db9f8f | ||
|
|
869ec3f670 | ||
|
|
70df4ca461 | ||
|
|
06ee8fdd47 | ||
|
|
5467685bd8 | ||
|
|
efaf75f2e6 | ||
|
|
ca430f0e7b | ||
|
|
fa61ad072d | ||
|
|
686598b6b3 | ||
|
|
5f5dcec0b9 | ||
|
|
2a5cb8da32 | ||
|
|
9b497aebb4 | ||
|
|
5f08883227 | ||
|
|
4b2073ca59 | ||
|
|
f98487ef18 | ||
|
|
dff964582b | ||
|
|
e5c36c8d56 | ||
|
|
d765b92cca | ||
|
|
ba266ab13c | ||
|
|
d4aa981fd7 | ||
|
|
ac0d0b21e2 | ||
|
|
092ab823d1 | ||
|
|
3ad2456dd9 | ||
|
|
146ec4e760 | ||
|
|
f563817b98 | ||
|
|
ec15a66a68 | ||
|
|
e708e30c33 | ||
|
|
383dd80919 | ||
|
|
6752bd450b | ||
|
|
b0780e1db5 | ||
|
|
02a5df0aee | ||
|
|
cb4a48ca02 | ||
|
|
64f997718a | ||
|
|
482cf261c0 | ||
|
|
1a5706a693 | ||
|
|
13c320902e | ||
|
|
bfbd01a4e5 | ||
|
|
09622e180e | ||
|
|
84486bad78 | ||
|
|
c486baccaa | ||
|
|
57554aba57 | ||
|
|
c9eb1a2e9c | ||
|
|
1a64489121 | ||
|
|
888ffc002f | ||
|
|
58419f14e8 | ||
|
|
57a5c7c8b6 | ||
|
|
b2b40d9ed6 | ||
|
|
8e578227c3 | ||
|
|
d3d522c463 | ||
|
|
53131390ac | ||
|
|
7820bcf218 | ||
|
|
49e2bfae31 | ||
|
|
55a1082866 | ||
|
|
5bb6272dfa | ||
|
|
850703824b | ||
|
|
72fdcd1cb1 | ||
|
|
33ea67e1d0 | ||
|
|
047100069b | ||
|
|
ecadf6a330 | ||
|
|
b41b7aeb5b | ||
|
|
c9b81a5c04 | ||
|
|
51d5e21203 | ||
|
|
621bd5f0c3 | ||
|
|
9b4cf873c1 | ||
|
|
e262f759af | ||
|
|
78a98afb8d | ||
|
|
e5f2046b19 | ||
|
|
d77b5cbbbf | ||
|
|
5dbd7684ce | ||
|
|
32a0463f47 | ||
|
|
62a3902de7 | ||
|
|
eb3686af06 | ||
|
|
81983d66f4 | ||
|
|
e015c0a6ae | ||
|
|
81c8ba87ab | ||
|
|
ec957e4a94 | ||
|
|
08d7beb803 | ||
|
|
131ce09490 | ||
|
|
e8eb1ed35c | ||
|
|
0c7ab2062f | ||
|
|
f1b933ae0c | ||
|
|
e6d0bc7d5d | ||
|
|
e795f81f73 | ||
|
|
3caffa4dad | ||
|
|
1f2c54f112 | ||
|
|
c12a79ecba | ||
|
|
a94e9d472b | ||
|
|
cde22a44db | ||
|
|
33d5ed52e6 | ||
|
|
78e24be1e7 | ||
|
|
a89853da9d | ||
|
|
3b01a57de3 | ||
|
|
256a2276ef | ||
|
|
6223fe93c8 | ||
|
|
7348a1fd0c | ||
|
|
8687b438f1 | ||
|
|
4901ecba7f | ||
|
|
59637d2391 | ||
|
|
fcccd85ac4 | ||
|
|
34b32ced25 | ||
|
|
d7c713d18d | ||
|
|
4876e35fd8 | ||
|
|
9198234465 | ||
|
|
be8e432bea | ||
|
|
943b1afb55 | ||
|
|
3e8d3083ac | ||
|
|
3c4f2c2dcf | ||
|
|
277883e756 | ||
|
|
5bda072141 | ||
|
|
a8e60a6c53 | ||
|
|
50da3c5c5b | ||
|
|
c5f9ff6ac5 | ||
|
|
6393f1f02d | ||
|
|
c81b9d624b | ||
|
|
cae992f5e6 | ||
|
|
9ecdee3b78 | ||
|
|
267dfac737 | ||
|
|
a08ffdc8d3 | ||
|
|
1ef4332af6 | ||
|
|
d0d2fd7918 | ||
|
|
c518c4756b | ||
|
|
a3a99cc631 | ||
|
|
977a55e3b8 | ||
|
|
002db3c3e9 | ||
|
|
d9e44bab69 | ||
|
|
4b93fc61b5 | ||
|
|
214b5efd72 | ||
|
|
9bd822d693 | ||
|
|
bf89eaae25 | ||
|
|
f9b359ae30 | ||
|
|
24ed003471 | ||
|
|
a835750252 | ||
|
|
ad07bdb62b | ||
|
|
41104324ec | ||
|
|
e9344ae101 | ||
|
|
56a9167ed2 | ||
|
|
e0b90c4b36 | ||
|
|
976902f22c | ||
|
|
0f69c58ba9 | ||
|
|
1e6c96c6eb | ||
|
|
63b14d14c1 | ||
|
|
6aaaba6419 | ||
|
|
3b8e736fe3 | ||
|
|
68841b3d8a | ||
|
|
3d8afe7cb8 | ||
|
|
8595242142 | ||
|
|
ebe7bc0686 | ||
|
|
4ab180f016 | ||
|
|
372649069e | ||
|
|
98df46f3ea | ||
|
|
269fb23527 | ||
|
|
ad5cbf0da6 | ||
|
|
10cdf64f90 | ||
|
|
ec8e639804 | ||
|
|
37f37f7287 | ||
|
|
ef7d68bfd6 | ||
|
|
058b012e6c | ||
|
|
71370758a8 | ||
|
|
38a44676eb | ||
|
|
05ce3d35b3 | ||
|
|
2151086b0a | ||
|
|
9c83af3789 | ||
|
|
ac3eecc879 | ||
|
|
ec0910e3da | ||
|
|
fd0c26cd56 | ||
|
|
a4c5dee082 | ||
|
|
37c09dbdb6 | ||
|
|
73d1973625 | ||
|
|
5a04a886cf | ||
|
|
50802f84f0 | ||
|
|
138b68ecc0 | ||
|
|
e0b01ee94e | ||
|
|
4f2c3df518 | ||
|
|
51a6bb1c22 | ||
|
|
6bf9ec69f3 | ||
|
|
21309eeb5d | ||
|
|
0a1b46c52f | ||
|
|
9512f9eec3 | ||
|
|
ab94422c18 | ||
|
|
ec105e5265 | ||
|
|
cadd8521ae | ||
|
|
8825c50671 | ||
|
|
a72cc3c248 | ||
|
|
780f7254c1 | ||
|
|
37621e77ae | ||
|
|
8017386c73 | ||
|
|
a5f4c25a2c | ||
|
|
1d7bddf449 | ||
|
|
711bdaf373 | ||
|
|
803d9c5a8e | ||
|
|
1133c41fa8 | ||
|
|
a06af7ee93 | ||
|
|
c54717707e | ||
|
|
440d83d754 | ||
|
|
1cf62916a7 | ||
|
|
e3958d4adb | ||
|
|
dfccd4abf9 | ||
|
|
994d6f552c | ||
|
|
b015611a2a | ||
|
|
f4e362c5d0 | ||
|
|
a542236614 | ||
|
|
651439ea06 | ||
|
|
eda450838e | ||
|
|
b906daa493 | ||
|
|
ac668dce7d | ||
|
|
1bb4d62a3e | ||
|
|
0b970f9a85 | ||
|
|
d2b695e7b5 | ||
|
|
b2f23c1a5e | ||
|
|
f403afb012 | ||
|
|
ee276aff44 | ||
|
|
0acd1dc5d1 | ||
|
|
21815e1621 | ||
|
|
15933bb16f | ||
|
|
930cd0dc50 | ||
|
|
fc4af48179 | ||
|
|
ba1cf84ea5 | ||
|
|
59cf01e252 | ||
|
|
46e681f4fc |
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -904,6 +904,7 @@ jobs:
|
||||
cov_params+=(--cov-report=xml)
|
||||
fi
|
||||
|
||||
echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
|
||||
python3 -b -X dev -m pytest \
|
||||
-qq \
|
||||
--timeout=9 \
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.25.13
|
||||
uses: github/codeql-action/init@v3.25.15
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.25.13
|
||||
uses: github/codeql-action/analyze@v3.25.15
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.5.4
|
||||
rev: v0.5.5
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
||||
@@ -120,6 +120,7 @@ homeassistant.components.bond.*
|
||||
homeassistant.components.braviatv.*
|
||||
homeassistant.components.brother.*
|
||||
homeassistant.components.browser.*
|
||||
homeassistant.components.bryant_evolution.*
|
||||
homeassistant.components.bthome.*
|
||||
homeassistant.components.button.*
|
||||
homeassistant.components.calendar.*
|
||||
@@ -167,6 +168,7 @@ homeassistant.components.ecowitt.*
|
||||
homeassistant.components.efergy.*
|
||||
homeassistant.components.electrasmart.*
|
||||
homeassistant.components.electric_kiwi.*
|
||||
homeassistant.components.elevenlabs.*
|
||||
homeassistant.components.elgato.*
|
||||
homeassistant.components.elkm1.*
|
||||
homeassistant.components.emulated_hue.*
|
||||
@@ -280,6 +282,7 @@ homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
homeassistant.components.linear_garage_door.*
|
||||
homeassistant.components.linkplay.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.local_ip.*
|
||||
|
||||
19
CODEOWNERS
19
CODEOWNERS
@@ -197,7 +197,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bluemaestro/ @bdraco
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
/tests/components/blueprint/ @home-assistant/core
|
||||
/homeassistant/components/bluesound/ @thrawnarn
|
||||
/homeassistant/components/bluesound/ @thrawnarn @LouisChrist
|
||||
/tests/components/bluesound/ @thrawnarn @LouisChrist
|
||||
/homeassistant/components/bluetooth/ @bdraco
|
||||
/tests/components/bluetooth/ @bdraco
|
||||
/homeassistant/components/bluetooth_adapters/ @bdraco
|
||||
@@ -220,6 +221,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/brottsplatskartan/ @gjohansson-ST
|
||||
/homeassistant/components/brunt/ @eavanvalkenburg
|
||||
/tests/components/brunt/ @eavanvalkenburg
|
||||
/homeassistant/components/bryant_evolution/ @danielsmyers
|
||||
/tests/components/bryant_evolution/ @danielsmyers
|
||||
/homeassistant/components/bsblan/ @liudger
|
||||
/tests/components/bsblan/ @liudger
|
||||
/homeassistant/components/bt_smarthub/ @typhoon2099
|
||||
@@ -373,6 +376,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
/tests/components/electric_kiwi/ @mikey0000
|
||||
/homeassistant/components/elevenlabs/ @sorgfresser
|
||||
/tests/components/elevenlabs/ @sorgfresser
|
||||
/homeassistant/components/elgato/ @frenck
|
||||
/tests/components/elgato/ @frenck
|
||||
/homeassistant/components/elkm1/ @gwww @bdraco
|
||||
@@ -384,6 +389,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/elvia/ @ludeeus
|
||||
/homeassistant/components/emby/ @mezz64
|
||||
/homeassistant/components/emoncms/ @borpin @alexandrecuer
|
||||
/tests/components/emoncms/ @borpin @alexandrecuer
|
||||
/homeassistant/components/emonitor/ @bdraco
|
||||
/tests/components/emonitor/ @bdraco
|
||||
/homeassistant/components/emulated_hue/ @bdraco @Tho85
|
||||
@@ -706,6 +712,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/iqvia/ @bachya
|
||||
/tests/components/iqvia/ @bachya
|
||||
/homeassistant/components/irish_rail_transport/ @ttroy50
|
||||
/homeassistant/components/iron_os/ @tr4nt0r
|
||||
/tests/components/iron_os/ @tr4nt0r
|
||||
/homeassistant/components/isal/ @bdraco
|
||||
/tests/components/isal/ @bdraco
|
||||
/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair
|
||||
@@ -794,6 +802,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/light/ @home-assistant/core
|
||||
/homeassistant/components/linear_garage_door/ @IceBotYT
|
||||
/tests/components/linear_garage_door/ @IceBotYT
|
||||
/homeassistant/components/linkplay/ @Velleman
|
||||
/tests/components/linkplay/ @Velleman
|
||||
/homeassistant/components/linux_battery/ @fabaff
|
||||
/homeassistant/components/litejet/ @joncar
|
||||
/tests/components/litejet/ @joncar
|
||||
@@ -836,7 +846,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lyric/ @timmo001
|
||||
/homeassistant/components/madvr/ @iloveicedgreentea
|
||||
/tests/components/madvr/ @iloveicedgreentea
|
||||
/homeassistant/components/mastodon/ @fabaff
|
||||
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/tests/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/homeassistant/components/matrix/ @PaarthShah
|
||||
/tests/components/matrix/ @PaarthShah
|
||||
/homeassistant/components/matter/ @home-assistant/matter
|
||||
@@ -1042,8 +1053,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
|
||||
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
|
||||
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
|
||||
@@ -18,9 +18,12 @@ from homeassistant.const import (
|
||||
EVENT_THEMES_UPDATED,
|
||||
)
|
||||
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
|
||||
from homeassistant.util.event_type import EventType
|
||||
|
||||
# These are events that do not contain any sensitive data
|
||||
@@ -41,4 +44,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_SHOPPING_LIST_UPDATED,
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "logitech",
|
||||
"name": "Logitech",
|
||||
"integrations": ["harmony", "ue_smart_radio", "squeezebox"]
|
||||
"integrations": ["harmony", "squeezebox"]
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC Mode and State."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return await self.async_turn_off()
|
||||
await self.async_turn_off()
|
||||
return
|
||||
if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO:
|
||||
raise ServiceValidationError("Heat/Cool is not supported in this mode")
|
||||
await self.async_update_ac(
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl
|
||||
from airgradient import (
|
||||
AirGradientClient,
|
||||
AirGradientError,
|
||||
AirGradientParseError,
|
||||
ConfigurationControl,
|
||||
)
|
||||
from awesomeversion import AwesomeVersion
|
||||
from mashumaro import MissingField
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
@@ -83,10 +87,10 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.client = AirGradientClient(user_input[CONF_HOST], session=session)
|
||||
try:
|
||||
current_measures = await self.client.get_current_measures()
|
||||
except AirGradientParseError:
|
||||
return self.async_abort(reason="invalid_version")
|
||||
except AirGradientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MissingField:
|
||||
return self.async_abort(reason="invalid_version")
|
||||
else:
|
||||
await self.async_set_unique_id(current_measures.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.7.0"],
|
||||
"requirements": ["airgradient==0.7.1"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -156,7 +156,8 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return await self.async_turn_off()
|
||||
await self.async_turn_off()
|
||||
return
|
||||
await self._airtouch.SetCoolingModeForAc(
|
||||
self._ac_number, HA_STATE_TO_AT[hvac_mode]
|
||||
)
|
||||
@@ -262,7 +263,8 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
raise ValueError(f"Unsupported HVAC mode: {hvac_mode}")
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return await self.async_turn_off()
|
||||
await self.async_turn_off()
|
||||
return
|
||||
if self.hvac_mode == HVACMode.OFF:
|
||||
await self.async_turn_on()
|
||||
self._unit = self._airtouch.GetGroups()[self._group_number]
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.COVER]
|
||||
|
||||
type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient]
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
|
||||
"""Base class for Airtouch5 Climate Entities."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
_attr_target_temperature_step = 1
|
||||
_attr_name = None
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
134
homeassistant/components/airtouch5/cover.py
Normal file
134
homeassistant/components/airtouch5/cover.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Representation of the Damper for AirTouch 5 Devices."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
from airtouch5py.packets.zone_control import (
|
||||
ZoneControlZone,
|
||||
ZoneSettingPower,
|
||||
ZoneSettingValue,
|
||||
)
|
||||
from airtouch5py.packets.zone_name import ZoneName
|
||||
from airtouch5py.packets.zone_status import ZoneStatusZone
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import Airtouch5ConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import Airtouch5Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: Airtouch5ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Airtouch 5 Cover entities."""
|
||||
client = config_entry.runtime_data
|
||||
|
||||
# Each zone has a cover for its open percentage
|
||||
async_add_entities(
|
||||
Airtouch5ZoneOpenPercentage(
|
||||
client, zone, client.latest_zone_status[zone.zone_number].has_sensor
|
||||
)
|
||||
for zone in client.zones
|
||||
)
|
||||
|
||||
|
||||
class Airtouch5ZoneOpenPercentage(CoverEntity, Airtouch5Entity):
|
||||
"""How open the damper is in each zone."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.DAMPER
|
||||
_attr_translation_key = "damper"
|
||||
|
||||
# Zones with temperature sensors shouldn't be manually controlled.
|
||||
# We allow it but warn the user in the integration documentation.
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.SET_POSITION
|
||||
| CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, client: Airtouch5SimpleClient, zone_name: ZoneName, has_sensor: bool
|
||||
) -> None:
|
||||
"""Initialise the Cover Entity."""
|
||||
super().__init__(client)
|
||||
self._zone_name = zone_name
|
||||
|
||||
self._attr_unique_id = f"zone_{zone_name.zone_number}_open_percentage"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"zone_{zone_name.zone_number}")},
|
||||
name=zone_name.zone_name,
|
||||
manufacturer="Polyaire",
|
||||
model="AirTouch 5",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None:
|
||||
if self._zone_name.zone_number not in data:
|
||||
return
|
||||
status = data[self._zone_name.zone_number]
|
||||
|
||||
self._attr_current_cover_position = int(status.open_percentage * 100)
|
||||
if status.open_percentage == 0:
|
||||
self._attr_is_closed = True
|
||||
else:
|
||||
self._attr_is_closed = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add data updated listener after this object has been initialized."""
|
||||
await super().async_added_to_hass()
|
||||
self._client.zone_status_callbacks.append(self._async_update_attrs)
|
||||
self._async_update_attrs(self._client.latest_zone_status)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove data updated listener after this object has been initialized."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._client.zone_status_callbacks.remove(self._async_update_attrs)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the damper."""
|
||||
await self._set_cover_position(100)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close damper."""
|
||||
await self._set_cover_position(0)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Update the damper to a specific position."""
|
||||
|
||||
if (position := kwargs.get(ATTR_POSITION)) is None:
|
||||
_LOGGER.debug("Argument `position` is missing in set_cover_position")
|
||||
return
|
||||
await self._set_cover_position(position)
|
||||
|
||||
async def _set_cover_position(self, position_percent: float) -> None:
|
||||
power: ZoneSettingPower
|
||||
|
||||
if position_percent == 0:
|
||||
power = ZoneSettingPower.SET_TO_OFF
|
||||
else:
|
||||
power = ZoneSettingPower.SET_TO_ON
|
||||
|
||||
zcz = ZoneControlZone(
|
||||
self._zone_name.zone_number,
|
||||
ZoneSettingValue.SET_OPEN_PERCENTAGE,
|
||||
power,
|
||||
position_percent / 100.0,
|
||||
)
|
||||
|
||||
packet = self._client.data_packet_factory.zone_control([zcz])
|
||||
await self._client.send_packet(packet)
|
||||
@@ -6,15 +6,12 @@ from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class Airtouch5Entity(Entity):
|
||||
"""Base class for Airtouch5 entities."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(self, client: Airtouch5SimpleClient) -> None:
|
||||
"""Initialise the Entity."""
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
"damper": {
|
||||
"name": "[%key:component::cover::entity_component::damper::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from aioairzone_cloud.const import (
|
||||
AZD_FLOOR_DEMAND,
|
||||
AZD_PROBLEMS,
|
||||
AZD_SYSTEMS,
|
||||
AZD_THERMOSTAT_BATTERY_LOW,
|
||||
AZD_WARNINGS,
|
||||
AZD_ZONES,
|
||||
)
|
||||
@@ -88,6 +89,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
|
||||
key=AZD_AQ_ACTIVE,
|
||||
translation_key="air_quality_active",
|
||||
),
|
||||
AirzoneBinarySensorEntityDescription(
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
key=AZD_THERMOSTAT_BATTERY_LOW,
|
||||
),
|
||||
AirzoneBinarySensorEntityDescription(
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
key=AZD_FLOOR_DEMAND,
|
||||
|
||||
@@ -13,9 +13,12 @@ from aioairzone_cloud.const import (
|
||||
AZD_GROUPS,
|
||||
AZD_HOT_WATERS,
|
||||
AZD_INSTALLATIONS,
|
||||
AZD_MODEL,
|
||||
AZD_NAME,
|
||||
AZD_SYSTEM_ID,
|
||||
AZD_SYSTEMS,
|
||||
AZD_THERMOSTAT_FW,
|
||||
AZD_THERMOSTAT_MODEL,
|
||||
AZD_WEBSERVER,
|
||||
AZD_WEBSERVERS,
|
||||
AZD_ZONES,
|
||||
@@ -69,6 +72,7 @@ class AirzoneAidooEntity(AirzoneEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, aidoo_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=aidoo_data[AZD_MODEL],
|
||||
name=aidoo_data[AZD_NAME],
|
||||
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
|
||||
)
|
||||
@@ -111,6 +115,7 @@ class AirzoneGroupEntity(AirzoneEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, group_id)},
|
||||
model="Group",
|
||||
manufacturer=MANUFACTURER,
|
||||
name=group_data[AZD_NAME],
|
||||
)
|
||||
@@ -154,6 +159,7 @@ class AirzoneHotWaterEntity(AirzoneEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, dhw_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model="Hot Water",
|
||||
name=dhw_data[AZD_NAME],
|
||||
via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]),
|
||||
)
|
||||
@@ -195,6 +201,7 @@ class AirzoneInstallationEntity(AirzoneEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, inst_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model="Installation",
|
||||
name=inst_data[AZD_NAME],
|
||||
)
|
||||
|
||||
@@ -240,9 +247,11 @@ class AirzoneSystemEntity(AirzoneEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, system_id)},
|
||||
model=system_data.get(AZD_MODEL),
|
||||
manufacturer=MANUFACTURER,
|
||||
name=system_data[AZD_NAME],
|
||||
via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
|
||||
sw_version=system_data.get(AZD_FIRMWARE),
|
||||
)
|
||||
|
||||
def get_airzone_value(self, key: str) -> Any:
|
||||
@@ -270,6 +279,7 @@ class AirzoneWebServerEntity(AirzoneEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
|
||||
identifiers={(DOMAIN, ws_id)},
|
||||
model="WebServer",
|
||||
manufacturer=MANUFACTURER,
|
||||
name=ws_data[AZD_NAME],
|
||||
sw_version=ws_data[AZD_FIRMWARE],
|
||||
@@ -300,9 +310,11 @@ class AirzoneZoneEntity(AirzoneEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, zone_id)},
|
||||
model=zone_data.get(AZD_THERMOSTAT_MODEL),
|
||||
manufacturer=MANUFACTURER,
|
||||
name=zone_data[AZD_NAME],
|
||||
via_device=(DOMAIN, self.system_id),
|
||||
sw_version=zone_data.get(AZD_THERMOSTAT_FW),
|
||||
)
|
||||
|
||||
def get_airzone_value(self, key: str) -> Any:
|
||||
|
||||
15
homeassistant/components/airzone_cloud/icons.json
Normal file
15
homeassistant/components/airzone_cloud/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cpu_usage": {
|
||||
"default": "mdi:cpu-32-bit"
|
||||
},
|
||||
"free_memory": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"thermostat_coverage": {
|
||||
"default": "mdi:signal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.5.5"]
|
||||
"requirements": ["aioairzone-cloud==0.6.1"]
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ from aioairzone_cloud.const import (
|
||||
AZD_AQ_PM_1,
|
||||
AZD_AQ_PM_2P5,
|
||||
AZD_AQ_PM_10,
|
||||
AZD_CPU_USAGE,
|
||||
AZD_HUMIDITY,
|
||||
AZD_MEMORY_FREE,
|
||||
AZD_TEMP,
|
||||
AZD_THERMOSTAT_BATTERY,
|
||||
AZD_THERMOSTAT_COVERAGE,
|
||||
AZD_WEBSERVERS,
|
||||
AZD_WIFI_RSSI,
|
||||
AZD_ZONES,
|
||||
@@ -28,6 +32,7 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfInformation,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -52,6 +57,22 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
||||
)
|
||||
|
||||
WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_CPU_USAGE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="cpu_usage",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_MEMORY_FREE,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="free_memory",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -98,6 +119,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
key=AZD_THERMOSTAT_BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key=AZD_THERMOSTAT_COVERAGE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="thermostat_coverage",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@
|
||||
"auto": "Auto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cpu_usage": {
|
||||
"name": "CPU usage"
|
||||
},
|
||||
"free_memory": {
|
||||
"name": "Free memory"
|
||||
},
|
||||
"thermostat_coverage": {
|
||||
"name": "Signal percentage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/alexa",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -4,3 +4,6 @@ from typing import Final
|
||||
|
||||
DOMAIN: Final = "apcupsd"
|
||||
CONNECTION_TIMEOUT: int = 10
|
||||
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LASTSTEST: Final = "laststest"
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfApparentPower,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
@@ -25,7 +26,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, LASTSTEST
|
||||
from .coordinator import APCUPSdCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -156,8 +157,8 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"laststest": SensorEntityDescription(
|
||||
key="laststest",
|
||||
LASTSTEST: SensorEntityDescription(
|
||||
key=LASTSTEST,
|
||||
translation_key="last_self_test",
|
||||
),
|
||||
"lastxfer": SensorEntityDescription(
|
||||
@@ -417,7 +418,12 @@ async def async_setup_entry(
|
||||
available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()}
|
||||
|
||||
entities = []
|
||||
for resource in available_resources:
|
||||
|
||||
# "laststest" is a special sensor that only appears when the APC UPS daemon has done a
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
for resource in available_resources | {LASTSTEST}:
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
@@ -473,6 +479,14 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update sensor attributes based on coordinator data."""
|
||||
key = self.entity_description.key.upper()
|
||||
# For most sensors the key will always be available for each refresh. However, some sensors
|
||||
# (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
|
||||
# performed) and may disappear again after certain event. So we mark the state as "unknown"
|
||||
# when it becomes unknown after such events.
|
||||
if key not in self.coordinator.data:
|
||||
self._attr_native_value = STATE_UNKNOWN
|
||||
return
|
||||
|
||||
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
@@ -118,7 +118,7 @@ class APICoreStateView(HomeAssistantView):
|
||||
Home Assistant core is running. Its primary use case is for supervisor
|
||||
to check if Home Assistant is running.
|
||||
"""
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
hass = request.app[KEY_HASS]
|
||||
migration = recorder.async_migration_in_progress(hass)
|
||||
live = recorder.async_migration_is_live(hass)
|
||||
recorder_state = {"migration_in_progress": migration, "migration_is_live": live}
|
||||
@@ -390,6 +390,27 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
)
|
||||
|
||||
context = self.context(request)
|
||||
if not hass.services.has_service(domain, service):
|
||||
raise HTTPBadRequest from ServiceNotFound(domain, service)
|
||||
|
||||
if response_requested := "return_response" in request.query:
|
||||
if (
|
||||
hass.services.supports_response(domain, service)
|
||||
is ha.SupportsResponse.NONE
|
||||
):
|
||||
return self.json_message(
|
||||
"Service does not support responses. Remove return_response from request.",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
elif (
|
||||
hass.services.supports_response(domain, service) is ha.SupportsResponse.ONLY
|
||||
):
|
||||
return self.json_message(
|
||||
"Service call requires responses but caller did not ask for responses. "
|
||||
"Add ?return_response to query parameters.",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
changed_states: list[json_fragment] = []
|
||||
|
||||
@ha.callback
|
||||
@@ -406,13 +427,14 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
|
||||
try:
|
||||
# shield the service call from cancellation on connection drop
|
||||
await shield(
|
||||
response = await shield(
|
||||
hass.services.async_call(
|
||||
domain,
|
||||
service,
|
||||
data, # type: ignore[arg-type]
|
||||
blocking=True,
|
||||
context=context,
|
||||
return_response=response_requested,
|
||||
)
|
||||
)
|
||||
except (vol.Invalid, ServiceNotFound) as ex:
|
||||
@@ -420,6 +442,11 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
finally:
|
||||
cancel_listen()
|
||||
|
||||
if response_requested:
|
||||
return self.json(
|
||||
{"changed_states": changed_states, "service_response": response}
|
||||
)
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ AUTH_EXCEPTIONS = (
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||
from .const import DEFAULT_PORT
|
||||
from .coordinator import ApSystemsDataCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -20,18 +20,43 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"total_power": { "name": "Total power" },
|
||||
"total_power_p1": { "name": "Power of P1" },
|
||||
"total_power_p2": { "name": "Power of P2" },
|
||||
"lifetime_production": { "name": "Total lifetime production" },
|
||||
"lifetime_production_p1": { "name": "Lifetime production of P1" },
|
||||
"lifetime_production_p2": { "name": "Lifetime production of P2" },
|
||||
"today_production": { "name": "Production of today" },
|
||||
"today_production_p1": { "name": "Production of today from P1" },
|
||||
"today_production_p2": { "name": "Production of today from P2" }
|
||||
"total_power": {
|
||||
"name": "Total power"
|
||||
},
|
||||
"total_power_p1": {
|
||||
"name": "Power of P1"
|
||||
},
|
||||
"total_power_p2": {
|
||||
"name": "Power of P2"
|
||||
},
|
||||
"lifetime_production": {
|
||||
"name": "Total lifetime production"
|
||||
},
|
||||
"lifetime_production_p1": {
|
||||
"name": "Lifetime production of P1"
|
||||
},
|
||||
"lifetime_production_p2": {
|
||||
"name": "Lifetime production of P2"
|
||||
},
|
||||
"today_production": {
|
||||
"name": "Production of today"
|
||||
},
|
||||
"today_production_p1": {
|
||||
"name": "Production of today from P1"
|
||||
},
|
||||
"today_production_p2": {
|
||||
"name": "Production of today from P2"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"max_output": { "name": "Max output" }
|
||||
"max_output": {
|
||||
"name": "Max output"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"inverter_status": {
|
||||
"name": "Inverter status"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
homeassistant/components/apsystems/switch.py
Normal file
56
homeassistant/components/apsystems/switch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""The power switch which can be toggled via the APsystems local API integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
from APsystemsEZ1 import Status
|
||||
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import ApSystemsConfigEntry, ApSystemsData
|
||||
from .entity import ApSystemsEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ApSystemsConfigEntry,
|
||||
add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the switch platform."""
|
||||
|
||||
add_entities([ApSystemsInverterSwitch(config_entry.runtime_data)], True)
|
||||
|
||||
|
||||
class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
|
||||
"""The switch class for APSystems switches."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
_attr_translation_key = "inverter_status"
|
||||
|
||||
def __init__(self, data: ApSystemsData) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(data)
|
||||
self._api = data.coordinator.api
|
||||
self._attr_unique_id = f"{data.device_id}_inverter_status"
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update switch status and availability."""
|
||||
try:
|
||||
status = await self._api.get_device_power_status()
|
||||
except (TimeoutError, ClientConnectionError):
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_is_on = status == Status.normal
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._api.set_device_power_status(0)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._api.set_device_power_status(1)
|
||||
@@ -19,7 +19,10 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
self._unit = unit
|
||||
|
||||
self._device_model = f"ASIN AQUA {self._unit.type}"
|
||||
if self._unit.type == "Remote":
|
||||
self._device_model = "ASIN Pool"
|
||||
else:
|
||||
self._device_model = f"ASIN AQUA {self._unit.type}"
|
||||
self._device_name = self._unit.name if self._unit.name else self._device_model
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioaseko"],
|
||||
"requirements": ["aioaseko==0.1.1"]
|
||||
"requirements": ["aioaseko==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ from .const import (
|
||||
DATA_LAST_WAKE_UP,
|
||||
DOMAIN,
|
||||
EVENT_RECORDING,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
SAMPLES_PER_CHUNK,
|
||||
)
|
||||
from .error import PipelineNotFound
|
||||
from .pipeline import (
|
||||
@@ -53,6 +57,10 @@ __all__ = (
|
||||
"PipelineNotFound",
|
||||
"WakeWordSettings",
|
||||
"EVENT_RECORDING",
|
||||
"SAMPLES_PER_CHUNK",
|
||||
"SAMPLE_RATE",
|
||||
"SAMPLE_WIDTH",
|
||||
"SAMPLE_CHANNELS",
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
72
homeassistant/components/assist_pipeline/audio_enhancer.py
Normal file
72
homeassistant/components/assist_pipeline/audio_enhancer.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Audio enhancement for Assist."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from pymicro_vad import MicroVad
|
||||
|
||||
from .const import BYTES_PER_CHUNK
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EnhancedAudioChunk:
|
||||
"""Enhanced audio chunk and metadata."""
|
||||
|
||||
audio: bytes
|
||||
"""Raw PCM audio @ 16Khz with 16-bit mono samples"""
|
||||
|
||||
timestamp_ms: int
|
||||
"""Timestamp relative to start of audio stream (milliseconds)"""
|
||||
|
||||
is_speech: bool | None
|
||||
"""True if audio chunk likely contains speech, False if not, None if unknown"""
|
||||
|
||||
|
||||
class AudioEnhancer(ABC):
|
||||
"""Base class for audio enhancement."""
|
||||
|
||||
def __init__(
|
||||
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
|
||||
) -> None:
|
||||
"""Initialize audio enhancer."""
|
||||
self.auto_gain = auto_gain
|
||||
self.noise_suppression = noise_suppression
|
||||
self.is_vad_enabled = is_vad_enabled
|
||||
|
||||
@abstractmethod
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
|
||||
|
||||
class MicroVadEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that just runs microVAD."""
|
||||
|
||||
def __init__(
|
||||
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
|
||||
) -> None:
|
||||
"""Initialize audio enhancer."""
|
||||
super().__init__(auto_gain, noise_suppression, is_vad_enabled)
|
||||
|
||||
self.vad: MicroVad | None = None
|
||||
self.threshold = 0.5
|
||||
|
||||
if self.is_vad_enabled:
|
||||
self.vad = MicroVad()
|
||||
_LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold)
|
||||
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
is_speech: bool | None = None
|
||||
|
||||
if self.vad is not None:
|
||||
# Run VAD
|
||||
assert len(audio) == BYTES_PER_CHUNK
|
||||
speech_prob = self.vad.Process10ms(audio)
|
||||
is_speech = speech_prob > self.threshold
|
||||
|
||||
return EnhancedAudioChunk(
|
||||
audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech
|
||||
)
|
||||
@@ -15,3 +15,10 @@ DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up"
|
||||
WAKE_WORD_COOLDOWN = 2 # seconds
|
||||
|
||||
EVENT_RECORDING = f"{DOMAIN}_recording"
|
||||
|
||||
SAMPLE_RATE = 16000 # hertz
|
||||
SAMPLE_WIDTH = 2 # bytes
|
||||
SAMPLE_CHANNELS = 1 # mono
|
||||
MS_PER_CHUNK = 10
|
||||
SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
|
||||
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"codeowners": ["@balloob", "@synesthesiam"],
|
||||
"dependencies": ["conversation", "stt", "tts", "wake_word"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["webrtc-noise-gain==1.2.3"]
|
||||
"requirements": ["pymicro-vad==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -13,14 +13,11 @@ from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
from typing import Any, Literal, cast
|
||||
import wave
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
from homeassistant.components import (
|
||||
conversation,
|
||||
media_source,
|
||||
@@ -52,12 +49,19 @@ from homeassistant.util import (
|
||||
)
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadEnhancer
|
||||
from .const import (
|
||||
BYTES_PER_CHUNK,
|
||||
CONF_DEBUG_RECORDING_DIR,
|
||||
DATA_CONFIG,
|
||||
DATA_LAST_WAKE_UP,
|
||||
DATA_MIGRATIONS,
|
||||
DOMAIN,
|
||||
MS_PER_CHUNK,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
SAMPLES_PER_CHUNK,
|
||||
WAKE_WORD_COOLDOWN,
|
||||
)
|
||||
from .error import (
|
||||
@@ -111,9 +115,6 @@ STORED_PIPELINE_RUNS = 10
|
||||
|
||||
SAVE_DELAY = 10
|
||||
|
||||
AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz
|
||||
AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples
|
||||
|
||||
|
||||
@callback
|
||||
def _async_resolve_default_pipeline_settings(
|
||||
@@ -503,8 +504,8 @@ class AudioSettings:
|
||||
is_vad_enabled: bool = True
|
||||
"""True if VAD is used to determine the end of the voice command."""
|
||||
|
||||
is_chunking_enabled: bool = True
|
||||
"""True if audio is automatically split into 10 ms chunks (required for VAD, etc.)"""
|
||||
silence_seconds: float = 0.5
|
||||
"""Seconds of silence after voice command has ended."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Verify settings post-initialization."""
|
||||
@@ -514,9 +515,6 @@ class AudioSettings:
|
||||
if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31):
|
||||
raise ValueError("auto_gain_dbfs must be in [0, 31]")
|
||||
|
||||
if self.needs_processor and (not self.is_chunking_enabled):
|
||||
raise ValueError("Chunking must be enabled for audio processing")
|
||||
|
||||
@property
|
||||
def needs_processor(self) -> bool:
|
||||
"""True if an audio processor is needed."""
|
||||
@@ -527,20 +525,6 @@ class AudioSettings:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessedAudioChunk:
|
||||
"""Processed audio chunk and metadata."""
|
||||
|
||||
audio: bytes
|
||||
"""Raw PCM audio @ 16Khz with 16-bit mono samples"""
|
||||
|
||||
timestamp_ms: int
|
||||
"""Timestamp relative to start of audio stream (milliseconds)"""
|
||||
|
||||
is_speech: bool | None
|
||||
"""True if audio chunk likely contains speech, False if not, None if unknown"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineRun:
|
||||
"""Running context for a pipeline."""
|
||||
@@ -573,10 +557,12 @@ class PipelineRun:
|
||||
debug_recording_queue: Queue[str | bytes | None] | None = None
|
||||
"""Queue to communicate with debug recording thread"""
|
||||
|
||||
audio_processor: AudioProcessor | None = None
|
||||
audio_enhancer: AudioEnhancer | None = None
|
||||
"""VAD/noise suppression/auto gain"""
|
||||
|
||||
audio_processor_buffer: AudioBuffer = field(init=False, repr=False)
|
||||
audio_chunking_buffer: AudioBuffer = field(
|
||||
default_factory=lambda: AudioBuffer(BYTES_PER_CHUNK)
|
||||
)
|
||||
"""Buffer used when splitting audio into chunks for audio processing"""
|
||||
|
||||
_device_id: str | None = None
|
||||
@@ -601,17 +587,12 @@ class PipelineRun:
|
||||
pipeline_data.pipeline_runs.add_run(self)
|
||||
|
||||
# Initialize with audio settings
|
||||
self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES)
|
||||
if self.audio_settings.needs_processor:
|
||||
# Delay import of webrtc so HA start up is not crashing
|
||||
# on older architectures (armhf).
|
||||
#
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
self.audio_processor = AudioProcessor(
|
||||
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
|
||||
# Default audio enhancer
|
||||
self.audio_enhancer = MicroVadEnhancer(
|
||||
self.audio_settings.auto_gain_dbfs,
|
||||
self.audio_settings.noise_suppression_level,
|
||||
self.audio_settings.is_vad_enabled,
|
||||
)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
@@ -688,8 +669,8 @@ class PipelineRun:
|
||||
|
||||
async def wake_word_detection(
|
||||
self,
|
||||
stream: AsyncIterable[ProcessedAudioChunk],
|
||||
audio_chunks_for_stt: list[ProcessedAudioChunk],
|
||||
stream: AsyncIterable[EnhancedAudioChunk],
|
||||
audio_chunks_for_stt: list[EnhancedAudioChunk],
|
||||
) -> wake_word.DetectionResult | None:
|
||||
"""Run wake-word-detection portion of pipeline. Returns detection result."""
|
||||
metadata_dict = asdict(
|
||||
@@ -732,10 +713,11 @@ class PipelineRun:
|
||||
# Audio chunk buffer. This audio will be forwarded to speech-to-text
|
||||
# after wake-word-detection.
|
||||
num_audio_chunks_to_buffer = int(
|
||||
(wake_word_settings.audio_seconds_to_buffer * 16000)
|
||||
/ AUDIO_PROCESSOR_SAMPLES
|
||||
(wake_word_settings.audio_seconds_to_buffer * SAMPLE_RATE)
|
||||
/ SAMPLES_PER_CHUNK
|
||||
)
|
||||
stt_audio_buffer: deque[ProcessedAudioChunk] | None = None
|
||||
|
||||
stt_audio_buffer: deque[EnhancedAudioChunk] | None = None
|
||||
if num_audio_chunks_to_buffer > 0:
|
||||
stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer)
|
||||
|
||||
@@ -797,7 +779,7 @@ class PipelineRun:
|
||||
# speech-to-text so the user does not have to pause before
|
||||
# speaking the voice command.
|
||||
audio_chunks_for_stt.extend(
|
||||
ProcessedAudioChunk(
|
||||
EnhancedAudioChunk(
|
||||
audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False
|
||||
)
|
||||
for chunk_ts in result.queued_audio
|
||||
@@ -819,18 +801,17 @@ class PipelineRun:
|
||||
|
||||
async def _wake_word_audio_stream(
|
||||
self,
|
||||
audio_stream: AsyncIterable[ProcessedAudioChunk],
|
||||
stt_audio_buffer: deque[ProcessedAudioChunk] | None,
|
||||
audio_stream: AsyncIterable[EnhancedAudioChunk],
|
||||
stt_audio_buffer: deque[EnhancedAudioChunk] | None,
|
||||
wake_word_vad: VoiceActivityTimeout | None,
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
sample_width: int = SAMPLE_WIDTH,
|
||||
) -> AsyncIterable[tuple[bytes, int]]:
|
||||
"""Yield audio chunks with timestamps (milliseconds since start of stream).
|
||||
|
||||
Adds audio to a ring buffer that will be forwarded to speech-to-text after
|
||||
detection. Times out if VAD detects enough silence.
|
||||
"""
|
||||
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
|
||||
async for chunk in audio_stream:
|
||||
if self.abort_wake_word_detection:
|
||||
raise WakeWordDetectionAborted
|
||||
@@ -845,6 +826,7 @@ class PipelineRun:
|
||||
stt_audio_buffer.append(chunk)
|
||||
|
||||
if wake_word_vad is not None:
|
||||
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
|
||||
if not wake_word_vad.process(chunk_seconds, chunk.is_speech):
|
||||
raise WakeWordTimeoutError(
|
||||
code="wake-word-timeout", message="Wake word was not detected"
|
||||
@@ -881,7 +863,7 @@ class PipelineRun:
|
||||
async def speech_to_text(
|
||||
self,
|
||||
metadata: stt.SpeechMetadata,
|
||||
stream: AsyncIterable[ProcessedAudioChunk],
|
||||
stream: AsyncIterable[EnhancedAudioChunk],
|
||||
) -> str:
|
||||
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
|
||||
# Create a background task to prepare the conversation agent
|
||||
@@ -916,7 +898,9 @@ class PipelineRun:
|
||||
# Transcribe audio stream
|
||||
stt_vad: VoiceCommandSegmenter | None = None
|
||||
if self.audio_settings.is_vad_enabled:
|
||||
stt_vad = VoiceCommandSegmenter()
|
||||
stt_vad = VoiceCommandSegmenter(
|
||||
silence_seconds=self.audio_settings.silence_seconds
|
||||
)
|
||||
|
||||
result = await self.stt_provider.async_process_audio_stream(
|
||||
metadata,
|
||||
@@ -957,18 +941,18 @@ class PipelineRun:
|
||||
|
||||
async def _speech_to_text_stream(
|
||||
self,
|
||||
audio_stream: AsyncIterable[ProcessedAudioChunk],
|
||||
audio_stream: AsyncIterable[EnhancedAudioChunk],
|
||||
stt_vad: VoiceCommandSegmenter | None,
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
sample_width: int = SAMPLE_WIDTH,
|
||||
) -> AsyncGenerator[bytes]:
|
||||
"""Yield audio chunks until VAD detects silence or speech-to-text completes."""
|
||||
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
|
||||
sent_vad_start = False
|
||||
async for chunk in audio_stream:
|
||||
self._capture_chunk(chunk.audio)
|
||||
|
||||
if stt_vad is not None:
|
||||
chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
|
||||
if not stt_vad.process(chunk_seconds, chunk.is_speech):
|
||||
# Silence detected at the end of voice command
|
||||
self.process_event(
|
||||
@@ -1072,8 +1056,8 @@ class PipelineRun:
|
||||
tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output
|
||||
if self.tts_audio_output == "wav":
|
||||
# 16 Khz, 16-bit mono
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = SAMPLE_RATE
|
||||
tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = SAMPLE_CHANNELS
|
||||
|
||||
try:
|
||||
options_supported = await tts.async_support_options(
|
||||
@@ -1218,53 +1202,31 @@ class PipelineRun:
|
||||
self.debug_recording_thread = None
|
||||
|
||||
async def process_volume_only(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
) -> AsyncGenerator[ProcessedAudioChunk]:
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
|
||||
ms_per_sample = sample_rate // 1000
|
||||
ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
|
||||
timestamp_ms = 0
|
||||
|
||||
async for chunk in audio_stream:
|
||||
if self.audio_settings.volume_multiplier != 1.0:
|
||||
chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier)
|
||||
|
||||
if self.audio_settings.is_chunking_enabled:
|
||||
# 10 ms chunking
|
||||
for chunk_10ms in chunk_samples(
|
||||
chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
|
||||
):
|
||||
yield ProcessedAudioChunk(
|
||||
audio=chunk_10ms,
|
||||
timestamp_ms=timestamp_ms,
|
||||
is_speech=None, # no VAD
|
||||
)
|
||||
timestamp_ms += ms_per_chunk
|
||||
else:
|
||||
# No chunking
|
||||
yield ProcessedAudioChunk(
|
||||
audio=chunk,
|
||||
for sub_chunk in chunk_samples(
|
||||
chunk, BYTES_PER_CHUNK, self.audio_chunking_buffer
|
||||
):
|
||||
yield EnhancedAudioChunk(
|
||||
audio=sub_chunk,
|
||||
timestamp_ms=timestamp_ms,
|
||||
is_speech=None, # no VAD
|
||||
)
|
||||
timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
|
||||
timestamp_ms += MS_PER_CHUNK
|
||||
|
||||
async def process_enhance_audio(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
sample_rate: int = 16000,
|
||||
sample_width: int = 2,
|
||||
) -> AsyncGenerator[ProcessedAudioChunk]:
|
||||
"""Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation."""
|
||||
assert self.audio_processor is not None
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
|
||||
assert self.audio_enhancer is not None
|
||||
|
||||
ms_per_sample = sample_rate // 1000
|
||||
ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
|
||||
timestamp_ms = 0
|
||||
|
||||
async for dirty_samples in audio_stream:
|
||||
if self.audio_settings.volume_multiplier != 1.0:
|
||||
# Static gain
|
||||
@@ -1272,18 +1234,12 @@ class PipelineRun:
|
||||
dirty_samples, self.audio_settings.volume_multiplier
|
||||
)
|
||||
|
||||
# Split into 10ms chunks for audio enhancements/VAD
|
||||
for dirty_10ms_chunk in chunk_samples(
|
||||
dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
|
||||
# Split into chunks for audio enhancements/VAD
|
||||
for dirty_chunk in chunk_samples(
|
||||
dirty_samples, BYTES_PER_CHUNK, self.audio_chunking_buffer
|
||||
):
|
||||
ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk)
|
||||
yield ProcessedAudioChunk(
|
||||
audio=ap_result.audio,
|
||||
timestamp_ms=timestamp_ms,
|
||||
is_speech=ap_result.is_speech,
|
||||
)
|
||||
|
||||
timestamp_ms += ms_per_chunk
|
||||
yield self.audio_enhancer.enhance_chunk(dirty_chunk, timestamp_ms)
|
||||
timestamp_ms += MS_PER_CHUNK
|
||||
|
||||
|
||||
def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
|
||||
@@ -1323,9 +1279,9 @@ def _pipeline_debug_recording_thread_proc(
|
||||
|
||||
wav_path = run_recording_dir / f"{message}.wav"
|
||||
wav_writer = wave.open(str(wav_path), "wb")
|
||||
wav_writer.setframerate(16000)
|
||||
wav_writer.setsampwidth(2)
|
||||
wav_writer.setnchannels(1)
|
||||
wav_writer.setframerate(SAMPLE_RATE)
|
||||
wav_writer.setsampwidth(SAMPLE_WIDTH)
|
||||
wav_writer.setnchannels(SAMPLE_CHANNELS)
|
||||
elif isinstance(message, bytes):
|
||||
# Chunk of 16-bit mono audio at 16Khz
|
||||
if wav_writer is not None:
|
||||
@@ -1368,8 +1324,8 @@ class PipelineInput:
|
||||
"""Run pipeline."""
|
||||
self.run.start(device_id=self.device_id)
|
||||
current_stage: PipelineStage | None = self.run.start_stage
|
||||
stt_audio_buffer: list[ProcessedAudioChunk] = []
|
||||
stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None
|
||||
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
||||
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
|
||||
|
||||
if self.stt_stream is not None:
|
||||
if self.run.audio_settings.needs_processor:
|
||||
@@ -1423,7 +1379,7 @@ class PipelineInput:
|
||||
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
|
||||
# This is basically an async itertools.chain.
|
||||
async def buffer_then_audio_stream() -> (
|
||||
AsyncGenerator[ProcessedAudioChunk]
|
||||
AsyncGenerator[EnhancedAudioChunk]
|
||||
):
|
||||
# Buffered audio
|
||||
for chunk in stt_audio_buffer:
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Final, cast
|
||||
from typing import Final
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,44 +34,6 @@ class VadSensitivity(StrEnum):
|
||||
return 1.0
|
||||
|
||||
|
||||
class VoiceActivityDetector(ABC):
|
||||
"""Base class for voice activity detectors (VAD)."""
|
||||
|
||||
@abstractmethod
|
||||
def is_speech(self, chunk: bytes) -> bool:
|
||||
"""Return True if audio chunk contains speech."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def samples_per_chunk(self) -> int | None:
|
||||
"""Return number of samples per chunk or None if chunking is not required."""
|
||||
|
||||
|
||||
class WebRtcVad(VoiceActivityDetector):
|
||||
"""Voice activity detector based on webrtc."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize webrtcvad."""
|
||||
# Delay import of webrtc so HA start up is not crashing
|
||||
# on older architectures (armhf).
|
||||
#
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
# Just VAD: no noise suppression or auto gain
|
||||
self._audio_processor = AudioProcessor(0, 0)
|
||||
|
||||
def is_speech(self, chunk: bytes) -> bool:
|
||||
"""Return True if audio chunk contains speech."""
|
||||
result = self._audio_processor.Process10ms(chunk)
|
||||
return cast(bool, result.is_speech)
|
||||
|
||||
@property
|
||||
def samples_per_chunk(self) -> int | None:
|
||||
"""Return 10 ms."""
|
||||
return int(0.01 * _SAMPLE_RATE) # 10 ms
|
||||
|
||||
|
||||
class AudioBuffer:
|
||||
"""Fixed-sized audio buffer with variable internal length."""
|
||||
|
||||
@@ -119,7 +80,7 @@ class VoiceCommandSegmenter:
|
||||
speech_seconds: float = 0.3
|
||||
"""Seconds of speech before voice command has started."""
|
||||
|
||||
silence_seconds: float = 0.5
|
||||
silence_seconds: float = 1.0
|
||||
"""Seconds of silence after voice command has ended."""
|
||||
|
||||
timeout_seconds: float = 15.0
|
||||
@@ -176,29 +137,38 @@ class VoiceCommandSegmenter:
|
||||
if self._speech_seconds_left <= 0:
|
||||
# Inside voice command
|
||||
self.in_command = True
|
||||
self._silence_seconds_left = self.silence_seconds
|
||||
_LOGGER.debug("Voice command started")
|
||||
else:
|
||||
# Reset if enough silence
|
||||
self._reset_seconds_left -= chunk_seconds
|
||||
if self._reset_seconds_left <= 0:
|
||||
self._speech_seconds_left = self.speech_seconds
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
elif not is_speech:
|
||||
# Silence in command
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
self._silence_seconds_left -= chunk_seconds
|
||||
if self._silence_seconds_left <= 0:
|
||||
# Command finished successfully
|
||||
self.reset()
|
||||
_LOGGER.debug("Voice command finished")
|
||||
return False
|
||||
else:
|
||||
# Reset if enough speech
|
||||
# Speech in command.
|
||||
# Reset silence counter if enough speech.
|
||||
self._reset_seconds_left -= chunk_seconds
|
||||
if self._reset_seconds_left <= 0:
|
||||
self._silence_seconds_left = self.silence_seconds
|
||||
self._reset_seconds_left = self.reset_seconds
|
||||
|
||||
return True
|
||||
|
||||
def process_with_vad(
|
||||
self,
|
||||
chunk: bytes,
|
||||
vad: VoiceActivityDetector,
|
||||
vad_samples_per_chunk: int | None,
|
||||
vad_is_speech: Callable[[bytes], bool],
|
||||
leftover_chunk_buffer: AudioBuffer | None,
|
||||
) -> bool:
|
||||
"""Process an audio chunk using an external VAD.
|
||||
@@ -207,20 +177,20 @@ class VoiceCommandSegmenter:
|
||||
|
||||
Returns False when voice command is finished.
|
||||
"""
|
||||
if vad.samples_per_chunk is None:
|
||||
if vad_samples_per_chunk is None:
|
||||
# No chunking
|
||||
chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE
|
||||
is_speech = vad.is_speech(chunk)
|
||||
is_speech = vad_is_speech(chunk)
|
||||
return self.process(chunk_seconds, is_speech)
|
||||
|
||||
if leftover_chunk_buffer is None:
|
||||
raise ValueError("leftover_chunk_buffer is required when vad uses chunking")
|
||||
|
||||
# With chunking
|
||||
seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE
|
||||
bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH
|
||||
seconds_per_chunk = vad_samples_per_chunk / _SAMPLE_RATE
|
||||
bytes_per_chunk = vad_samples_per_chunk * _SAMPLE_WIDTH
|
||||
for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer):
|
||||
is_speech = vad.is_speech(vad_chunk)
|
||||
is_speech = vad_is_speech(vad_chunk)
|
||||
if not self.process(seconds_per_chunk, is_speech):
|
||||
return False
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ from .const import (
|
||||
DEFAULT_WAKE_WORD_TIMEOUT,
|
||||
DOMAIN,
|
||||
EVENT_RECORDING,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
)
|
||||
from .error import PipelineNotFound
|
||||
from .pipeline import (
|
||||
@@ -92,7 +95,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None:
|
||||
vol.Optional("volume_multiplier"): float,
|
||||
# Advanced use cases/testing
|
||||
vol.Optional("no_vad"): bool,
|
||||
vol.Optional("no_chunking"): bool,
|
||||
}
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -170,9 +172,14 @@ async def websocket_run(
|
||||
|
||||
# Yield until we receive an empty chunk
|
||||
while chunk := await audio_queue.get():
|
||||
if incoming_sample_rate != 16000:
|
||||
if incoming_sample_rate != SAMPLE_RATE:
|
||||
chunk, state = audioop.ratecv(
|
||||
chunk, 2, 1, incoming_sample_rate, 16000, state
|
||||
chunk,
|
||||
SAMPLE_WIDTH,
|
||||
SAMPLE_CHANNELS,
|
||||
incoming_sample_rate,
|
||||
SAMPLE_RATE,
|
||||
state,
|
||||
)
|
||||
yield chunk
|
||||
|
||||
@@ -206,7 +213,6 @@ async def websocket_run(
|
||||
auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0),
|
||||
volume_multiplier=msg_input.get("volume_multiplier", 1.0),
|
||||
is_vad_enabled=not msg_input.get("no_vad", False),
|
||||
is_chunking_enabled=not msg_input.get("no_chunking", False),
|
||||
)
|
||||
elif start_stage == PipelineStage.INTENT:
|
||||
# Input to conversation agent
|
||||
@@ -424,9 +430,9 @@ def websocket_list_languages(
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"languages": sorted(pipeline_languages)
|
||||
if pipeline_languages
|
||||
else pipeline_languages
|
||||
"languages": (
|
||||
sorted(pipeline_languages) if pipeline_languages else pipeline_languages
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ NOTIFICATION_TITLE = "August"
|
||||
|
||||
MANUFACTURER = "August Home Inc."
|
||||
|
||||
DEFAULT_AUGUST_CONFIG_FILE = ".august.conf"
|
||||
|
||||
DEFAULT_NAME = "August"
|
||||
DOMAIN = "august"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN as AXIS_DOMAIN, PLATFORMS
|
||||
from .const import PLATFORMS
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
@@ -18,8 +18,6 @@ type AxisConfigEntry = ConfigEntry[AxisHub]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) -> bool:
|
||||
"""Set up the Axis integration."""
|
||||
hass.data.setdefault(AXIS_DOMAIN, {})
|
||||
|
||||
try:
|
||||
api = await get_axis_api(hass, config_entry.data)
|
||||
except CannotConnect as err:
|
||||
|
||||
@@ -135,6 +135,15 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
except AddressValueError:
|
||||
return self.async_abort(reason="ipv6_address")
|
||||
|
||||
# Check connection to ensure valid address is received
|
||||
self._client = MozartClient(self._host)
|
||||
|
||||
async with self._client:
|
||||
try:
|
||||
await self._client.get_beolink_self(_request_timeout=3)
|
||||
except (ClientConnectorError, TimeoutError):
|
||||
return self.async_abort(reason="invalid_address")
|
||||
|
||||
self._model = discovery_info.hostname[:-16].replace("-", " ")
|
||||
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
|
||||
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"
|
||||
|
||||
@@ -245,14 +245,36 @@ async def fetch_blueprint_from_website_url(
|
||||
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
|
||||
|
||||
|
||||
async def fetch_blueprint_from_generic_url(
|
||||
hass: HomeAssistant, url: str
|
||||
) -> ImportedBlueprint:
|
||||
"""Get a blueprint from a generic website."""
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
|
||||
resp = await session.get(url, raise_for_status=True)
|
||||
raw_yaml = await resp.text()
|
||||
data = yaml.parse_yaml(raw_yaml)
|
||||
|
||||
assert isinstance(data, dict)
|
||||
blueprint = Blueprint(data)
|
||||
|
||||
parsed_import_url = yarl.URL(url)
|
||||
suggested_filename = f"{parsed_import_url.host}/{parsed_import_url.parts[-1][:-5]}"
|
||||
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
|
||||
|
||||
|
||||
FETCH_FUNCTIONS = (
|
||||
fetch_blueprint_from_community_post,
|
||||
fetch_blueprint_from_github_url,
|
||||
fetch_blueprint_from_github_gist_url,
|
||||
fetch_blueprint_from_website_url,
|
||||
fetch_blueprint_from_generic_url,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint:
|
||||
"""Get a blueprint from a url."""
|
||||
for func in (
|
||||
fetch_blueprint_from_community_post,
|
||||
fetch_blueprint_from_github_url,
|
||||
fetch_blueprint_from_github_gist_url,
|
||||
fetch_blueprint_from_website_url,
|
||||
):
|
||||
for func in FETCH_FUNCTIONS:
|
||||
with suppress(UnsupportedUrl):
|
||||
imported_bp = await func(hass, url)
|
||||
imported_bp.blueprint.update_metadata(source_url=url)
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.const import (
|
||||
CONF_PATH,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import yaml
|
||||
|
||||
@@ -372,7 +372,7 @@ class DomainBlueprints:
|
||||
|
||||
shutil.copytree(
|
||||
integration.file_path / BLUEPRINT_FOLDER,
|
||||
self.blueprint_folder / HA_DOMAIN,
|
||||
self.blueprint_folder / HOMEASSISTANT_DOMAIN,
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(populate)
|
||||
|
||||
@@ -1 +1,70 @@
|
||||
"""The bluesound component."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
from pyblu import Player, SyncStatus
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .media_player import setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluesoundData:
|
||||
"""Bluesound data class."""
|
||||
|
||||
player: Player
|
||||
sync_status: SyncStatus
|
||||
|
||||
|
||||
type BluesoundConfigEntry = ConfigEntry[BluesoundData]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bluesound."""
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = []
|
||||
setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: BluesoundConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the Bluesound entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
port = config_entry.data[CONF_PORT]
|
||||
session = async_get_clientsession(hass)
|
||||
async with Player(host, port, session=session, default_timeout=10) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timeout while connecting to {host}:{port}"
|
||||
) from ex
|
||||
except aiohttp.ClientError as ex:
|
||||
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
|
||||
|
||||
config_entry.runtime_data = BluesoundData(player, sync_status)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
150
homeassistant/components/bluesound/config_flow.py
Normal file
150
homeassistant/components/bluesound/config_flow.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Config flow for bluesound."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pyblu import Player, SyncStatus
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .media_player import DEFAULT_PORT
|
||||
from .utils import format_unique_id
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Bluesound config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._host: str | None = None
|
||||
self._port = DEFAULT_PORT
|
||||
self._sync_status: SyncStatus | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with Player(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], session=session
|
||||
) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
format_unique_id(sync_status.mac, user_input[CONF_PORT])
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=sync_status.name,
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Optional(CONF_PORT, default=11000): int,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import bluesound config entry from configuration.yaml."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with Player(
|
||||
import_data[CONF_HOST], import_data[CONF_PORT], session=session
|
||||
) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(
|
||||
format_unique_id(sync_status.mac, import_data[CONF_PORT])
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=sync_status.name,
|
||||
data=import_data,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
if discovery_info.port is not None:
|
||||
self._port = discovery_info.port
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with Player(
|
||||
discovery_info.host, self._port, session=session
|
||||
) as player:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port))
|
||||
|
||||
self._host = discovery_info.host
|
||||
self._sync_status = sync_status
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self._host,
|
||||
}
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": sync_status.name},
|
||||
"configuration_url": f"http://{discovery_info.host}",
|
||||
}
|
||||
)
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Confirm the zeroconf setup."""
|
||||
assert self._sync_status is not None
|
||||
assert self._host is not None
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._sync_status.name,
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: self._port,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"name": self._sync_status.name,
|
||||
"host": self._host,
|
||||
},
|
||||
)
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Constants for the Bluesound HiFi wireless speakers and audio integrations component."""
|
||||
|
||||
DOMAIN = "bluesound"
|
||||
INTEGRATION_TITLE = "Bluesound"
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"domain": "bluesound",
|
||||
"name": "Bluesound",
|
||||
"codeowners": ["@thrawnarn"],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@thrawnarn", "@LouisChrist"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyblu==0.4.0"]
|
||||
"requirements": ["pyblu==0.4.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_musc._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import CancelledError
|
||||
from asyncio import CancelledError, Task
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||
@@ -23,40 +23,50 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_HOSTS,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import (
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_BLUESOUND_GROUP,
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
INTEGRATION_TITLE,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .utils import format_unique_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BluesoundConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
DATA_BLUESOUND = "bluesound"
|
||||
DATA_BLUESOUND = DOMAIN
|
||||
DEFAULT_PORT = 11000
|
||||
|
||||
NODE_OFFLINE_CHECK_TIMEOUT = 180
|
||||
@@ -83,6 +93,10 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
|
||||
|
||||
BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
|
||||
|
||||
|
||||
class ServiceMethodDetails(NamedTuple):
|
||||
"""Details for SERVICE_TO_METHOD mapping."""
|
||||
@@ -91,10 +105,6 @@ class ServiceMethodDetails(NamedTuple):
|
||||
schema: vol.Schema
|
||||
|
||||
|
||||
BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
|
||||
|
||||
BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA),
|
||||
SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA),
|
||||
@@ -107,78 +117,51 @@ SERVICE_TO_METHOD = {
|
||||
}
|
||||
|
||||
|
||||
def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None):
|
||||
"""Add Bluesound players."""
|
||||
|
||||
@callback
|
||||
def _init_player(event=None):
|
||||
"""Start polling."""
|
||||
hass.async_create_task(player.async_init())
|
||||
|
||||
@callback
|
||||
def _start_polling(event=None):
|
||||
"""Start polling."""
|
||||
player.start_polling()
|
||||
|
||||
@callback
|
||||
def _stop_polling(event=None):
|
||||
"""Stop polling."""
|
||||
player.stop_polling()
|
||||
|
||||
@callback
|
||||
def _add_player_cb():
|
||||
"""Add player after first sync fetch."""
|
||||
if player.id in [x.id for x in hass.data[DATA_BLUESOUND]]:
|
||||
_LOGGER.warning("Player already added %s", player.id)
|
||||
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Import config entry from configuration.yaml."""
|
||||
if not hass.config_entries.async_entries(DOMAIN):
|
||||
# Start import flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
if (
|
||||
result["type"] == FlowResultType.ABORT
|
||||
and result["reason"] == "cannot_connect"
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
breaks_in_ha_version="2025.2.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
hass.data[DATA_BLUESOUND].append(player)
|
||||
async_add_entities([player])
|
||||
_LOGGER.info("Added device with name: %s", player.name)
|
||||
|
||||
if hass.is_running:
|
||||
_start_polling()
|
||||
else:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_polling)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
|
||||
|
||||
player = BluesoundPlayer(hass, host, port, name, _add_player_cb)
|
||||
|
||||
if hass.is_running:
|
||||
_init_player()
|
||||
else:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.2.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Bluesound platforms."""
|
||||
if DATA_BLUESOUND not in hass.data:
|
||||
hass.data[DATA_BLUESOUND] = []
|
||||
|
||||
if discovery_info:
|
||||
_add_player(
|
||||
hass,
|
||||
async_add_entities,
|
||||
discovery_info.get(CONF_HOST),
|
||||
discovery_info.get(CONF_PORT),
|
||||
)
|
||||
return
|
||||
|
||||
if hosts := config.get(CONF_HOSTS):
|
||||
for host in hosts:
|
||||
_add_player(
|
||||
hass,
|
||||
async_add_entities,
|
||||
host.get(CONF_HOST),
|
||||
host.get(CONF_PORT),
|
||||
host.get(CONF_NAME),
|
||||
)
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Bluesound component."""
|
||||
|
||||
async def async_service_handler(service: ServiceCall) -> None:
|
||||
"""Map services to method of Bluesound devices."""
|
||||
@@ -190,12 +173,10 @@ async def async_setup_platform(
|
||||
}
|
||||
if entity_ids := service.data.get(ATTR_ENTITY_ID):
|
||||
target_players = [
|
||||
player
|
||||
for player in hass.data[DATA_BLUESOUND]
|
||||
if player.entity_id in entity_ids
|
||||
player for player in hass.data[DOMAIN] if player.entity_id in entity_ids
|
||||
]
|
||||
else:
|
||||
target_players = hass.data[DATA_BLUESOUND]
|
||||
target_players = hass.data[DOMAIN]
|
||||
|
||||
for player in target_players:
|
||||
await getattr(player, method.method)(**params)
|
||||
@@ -206,42 +187,92 @@ async def async_setup_platform(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BluesoundConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Bluesound entry."""
|
||||
bluesound_player = BluesoundPlayer(
|
||||
config_entry.data[CONF_HOST],
|
||||
config_entry.data[CONF_PORT],
|
||||
config_entry.runtime_data.player,
|
||||
config_entry.runtime_data.sync_status,
|
||||
)
|
||||
|
||||
hass.data[DATA_BLUESOUND].append(bluesound_player)
|
||||
async_add_entities([bluesound_player])
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None,
|
||||
) -> None:
|
||||
"""Trigger import flows."""
|
||||
hosts = config.get(CONF_HOSTS, [])
|
||||
for host in hosts:
|
||||
import_data = {
|
||||
CONF_HOST: host[CONF_HOST],
|
||||
CONF_PORT: host.get(CONF_PORT, 11000),
|
||||
}
|
||||
hass.async_create_task(_async_import(hass, import_data))
|
||||
|
||||
|
||||
class BluesoundPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Bluesound Player."""
|
||||
|
||||
_attr_media_content_type = MediaType.MUSIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, host, port=None, name=None, init_callback=None
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
player: Player,
|
||||
sync_status: SyncStatus,
|
||||
) -> None:
|
||||
"""Initialize the media player."""
|
||||
self.host = host
|
||||
self._hass = hass
|
||||
self.port = port
|
||||
self._polling_task = None # The actual polling task.
|
||||
self._name = name
|
||||
self._id = None
|
||||
self._polling_task: Task[None] | None = None # The actual polling task.
|
||||
self._id = sync_status.id
|
||||
self._last_status_update = None
|
||||
self._sync_status: SyncStatus | None = None
|
||||
self._sync_status = sync_status
|
||||
self._status: Status | None = None
|
||||
self._inputs: list[Input] = []
|
||||
self._presets: list[Preset] = []
|
||||
self._is_online = False
|
||||
self._retry_remove = None
|
||||
self._muted = False
|
||||
self._master: BluesoundPlayer | None = None
|
||||
self._is_master = False
|
||||
self._group_name = None
|
||||
self._group_list: list[str] = []
|
||||
self._bluesound_device_name = None
|
||||
self._player = Player(
|
||||
host, port, async_get_clientsession(hass), default_timeout=10
|
||||
)
|
||||
self._bluesound_device_name = sync_status.name
|
||||
self._player = player
|
||||
|
||||
self._init_callback = init_callback
|
||||
|
||||
if self.port is None:
|
||||
self.port = DEFAULT_PORT
|
||||
self._attr_unique_id = format_unique_id(sync_status.mac, port)
|
||||
# there should always be one player with the default port per mac
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
model_id=sync_status.model,
|
||||
)
|
||||
else:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
model_id=sync_status.model,
|
||||
via_device=(DOMAIN, format_mac(sync_status.mac)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_get_index(string, search_string):
|
||||
@@ -251,25 +282,18 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
async def force_update_sync_status(self, on_updated_cb=None) -> bool:
|
||||
async def force_update_sync_status(self) -> bool:
|
||||
"""Update the internal status."""
|
||||
sync_status = await self._player.sync_status()
|
||||
|
||||
self._sync_status = sync_status
|
||||
|
||||
if not self._name:
|
||||
self._name = sync_status.name if sync_status.name else self.host
|
||||
if not self._id:
|
||||
self._id = sync_status.id
|
||||
if not self._bluesound_device_name:
|
||||
self._bluesound_device_name = self._name
|
||||
|
||||
if sync_status.master is not None:
|
||||
self._is_master = False
|
||||
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
|
||||
master_device = [
|
||||
device
|
||||
for device in self._hass.data[DATA_BLUESOUND]
|
||||
for device in self.hass.data[DATA_BLUESOUND]
|
||||
if device.id == master_id
|
||||
]
|
||||
|
||||
@@ -284,8 +308,6 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
slaves = self._sync_status.slaves
|
||||
self._is_master = slaves is not None
|
||||
|
||||
if on_updated_cb:
|
||||
on_updated_cb()
|
||||
return True
|
||||
|
||||
async def _start_poll_command(self):
|
||||
@@ -295,7 +317,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
await self.async_update_status()
|
||||
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||
_LOGGER.error("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
self.start_polling()
|
||||
|
||||
@@ -305,32 +327,21 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
_LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
|
||||
raise
|
||||
|
||||
def start_polling(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Start the polling task."""
|
||||
self._polling_task = self._hass.async_create_task(self._start_poll_command())
|
||||
await super().async_added_to_hass()
|
||||
|
||||
def stop_polling(self):
|
||||
self._polling_task = self.hass.async_create_task(self._start_poll_command())
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop the polling task."""
|
||||
self._polling_task.cancel()
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def async_init(self, triggered=None):
|
||||
"""Initialize the player async."""
|
||||
try:
|
||||
if self._retry_remove is not None:
|
||||
self._retry_remove()
|
||||
self._retry_remove = None
|
||||
assert self._polling_task is not None
|
||||
if self._polling_task.cancel():
|
||||
await self._polling_task
|
||||
|
||||
await self.force_update_sync_status(self._init_callback)
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
|
||||
self._retry_remove = async_track_time_interval(
|
||||
self._hass, self.async_init, NODE_RETRY_INITIATION
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected when initiating error in %s:%s", self.host, self.port
|
||||
)
|
||||
raise
|
||||
self.hass.data[DATA_BLUESOUND].remove(self)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update internal status of the entity."""
|
||||
@@ -384,26 +395,23 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
self._last_status_update = None
|
||||
self._status = None
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.info("Client connection error, marking %s as offline", self._name)
|
||||
_LOGGER.error(
|
||||
"Client connection error, marking %s as offline",
|
||||
self._bluesound_device_name,
|
||||
)
|
||||
raise
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return an unique ID."""
|
||||
assert self._sync_status is not None
|
||||
return f"{format_mac(self._sync_status.mac)}-{self.port}"
|
||||
|
||||
async def async_trigger_sync_on_all(self):
|
||||
"""Trigger sync status update on all devices."""
|
||||
_LOGGER.debug("Trigger sync status on all devices")
|
||||
|
||||
for player in self._hass.data[DATA_BLUESOUND]:
|
||||
for player in self.hass.data[DATA_BLUESOUND]:
|
||||
await player.force_update_sync_status()
|
||||
|
||||
@Throttle(SYNC_STATUS_INTERVAL)
|
||||
async def async_update_sync_status(self, on_updated_cb=None):
|
||||
async def async_update_sync_status(self):
|
||||
"""Update sync status."""
|
||||
await self.force_update_sync_status(on_updated_cb)
|
||||
await self.force_update_sync_status()
|
||||
|
||||
@Throttle(UPDATE_CAPTURE_INTERVAL)
|
||||
async def async_update_captures(self) -> list[Input] | None:
|
||||
@@ -522,7 +530,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
if self._status is not None:
|
||||
volume = self._status.volume
|
||||
if self.is_grouped and self._sync_status is not None:
|
||||
if self.is_grouped:
|
||||
volume = self._sync_status.volume
|
||||
|
||||
if volume is None:
|
||||
@@ -537,7 +545,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
if self._status is not None:
|
||||
mute = self._status.mute
|
||||
if self.is_grouped and self._sync_status is not None:
|
||||
if self.is_grouped:
|
||||
mute = self._sync_status.mute_volume is not None
|
||||
|
||||
return mute
|
||||
@@ -547,11 +555,6 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
"""Get id of device."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def bluesound_device_name(self) -> str | None:
|
||||
"""Return the device name as returned by the device."""
|
||||
@@ -690,7 +693,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
device_group = self._group_name.split("+")
|
||||
|
||||
sorted_entities = sorted(
|
||||
self._hass.data[DATA_BLUESOUND],
|
||||
self.hass.data[DATA_BLUESOUND],
|
||||
key=lambda entity: entity.is_master,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Bluesound player",
|
||||
"port": "Port of your Bluesound player. This is usually 11000."
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Discover Bluesound player",
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"join": {
|
||||
"name": "Join",
|
||||
|
||||
8
homeassistant/components/bluesound/utils.py
Normal file
8
homeassistant/components/bluesound/utils.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Utility functions for the Bluesound component."""
|
||||
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
|
||||
def format_unique_id(mac: str, port: int) -> str:
|
||||
"""Generate a unique ID based on the MAC address and port number."""
|
||||
return f"{format_mac(mac)}-{port}"
|
||||
@@ -18,7 +18,7 @@
|
||||
"bleak-retry-connector==3.5.0",
|
||||
"bluetooth-adapters==0.19.3",
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.19.3",
|
||||
"bluetooth-data-tools==1.19.4",
|
||||
"dbus-fast==2.22.1",
|
||||
"habluetooth==3.1.3"
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache
|
||||
@@ -173,10 +174,10 @@ class BluetoothMatcherIndexBase[
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the matcher index."""
|
||||
self.local_name: dict[str, list[_T]] = {}
|
||||
self.service_uuid: dict[str, list[_T]] = {}
|
||||
self.service_data_uuid: dict[str, list[_T]] = {}
|
||||
self.manufacturer_id: dict[int, list[_T]] = {}
|
||||
self.local_name: defaultdict[str, list[_T]] = defaultdict(list)
|
||||
self.service_uuid: defaultdict[str, list[_T]] = defaultdict(list)
|
||||
self.service_data_uuid: defaultdict[str, list[_T]] = defaultdict(list)
|
||||
self.manufacturer_id: defaultdict[int, list[_T]] = defaultdict(list)
|
||||
self.service_uuid_set: set[str] = set()
|
||||
self.service_data_uuid_set: set[str] = set()
|
||||
self.manufacturer_id_set: set[int] = set()
|
||||
@@ -190,26 +191,22 @@ class BluetoothMatcherIndexBase[
|
||||
"""
|
||||
# Local name is the cheapest to match since its just a dict lookup
|
||||
if LOCAL_NAME in matcher:
|
||||
self.local_name.setdefault(
|
||||
_local_name_to_index_key(matcher[LOCAL_NAME]), []
|
||||
).append(matcher)
|
||||
self.local_name[_local_name_to_index_key(matcher[LOCAL_NAME])].append(
|
||||
matcher
|
||||
)
|
||||
return True
|
||||
|
||||
# Manufacturer data is 2nd cheapest since its all ints
|
||||
if MANUFACTURER_ID in matcher:
|
||||
self.manufacturer_id.setdefault(matcher[MANUFACTURER_ID], []).append(
|
||||
matcher
|
||||
)
|
||||
self.manufacturer_id[matcher[MANUFACTURER_ID]].append(matcher)
|
||||
return True
|
||||
|
||||
if SERVICE_UUID in matcher:
|
||||
self.service_uuid.setdefault(matcher[SERVICE_UUID], []).append(matcher)
|
||||
self.service_uuid[matcher[SERVICE_UUID]].append(matcher)
|
||||
return True
|
||||
|
||||
if SERVICE_DATA_UUID in matcher:
|
||||
self.service_data_uuid.setdefault(matcher[SERVICE_DATA_UUID], []).append(
|
||||
matcher
|
||||
)
|
||||
self.service_data_uuid[matcher[SERVICE_DATA_UUID]].append(matcher)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -260,32 +257,38 @@ class BluetoothMatcherIndexBase[
|
||||
if ble_device_matches(matcher, service_info)
|
||||
)
|
||||
|
||||
if self.service_data_uuid_set and service_info.service_data:
|
||||
if (
|
||||
(service_data_uuid_set := self.service_data_uuid_set)
|
||||
and (service_data := service_info.service_data)
|
||||
and (matched_uuids := service_data_uuid_set.intersection(service_data))
|
||||
):
|
||||
matches.extend(
|
||||
matcher
|
||||
for service_data_uuid in self.service_data_uuid_set.intersection(
|
||||
service_info.service_data
|
||||
)
|
||||
for service_data_uuid in matched_uuids
|
||||
for matcher in self.service_data_uuid[service_data_uuid]
|
||||
if ble_device_matches(matcher, service_info)
|
||||
)
|
||||
|
||||
if self.manufacturer_id_set and service_info.manufacturer_data:
|
||||
if (
|
||||
(manufacturer_id_set := self.manufacturer_id_set)
|
||||
and (manufacturer_data := service_info.manufacturer_data)
|
||||
and (matched_ids := manufacturer_id_set.intersection(manufacturer_data))
|
||||
):
|
||||
matches.extend(
|
||||
matcher
|
||||
for manufacturer_id in self.manufacturer_id_set.intersection(
|
||||
service_info.manufacturer_data
|
||||
)
|
||||
for manufacturer_id in matched_ids
|
||||
for matcher in self.manufacturer_id[manufacturer_id]
|
||||
if ble_device_matches(matcher, service_info)
|
||||
)
|
||||
|
||||
if self.service_uuid_set and service_info.service_uuids:
|
||||
if (
|
||||
(service_uuid_set := self.service_uuid_set)
|
||||
and (service_uuids := service_info.service_uuids)
|
||||
and (matched_uuids := service_uuid_set.intersection(service_uuids))
|
||||
):
|
||||
matches.extend(
|
||||
matcher
|
||||
for service_uuid in self.service_uuid_set.intersection(
|
||||
service_info.service_uuids
|
||||
)
|
||||
for service_uuid in matched_uuids
|
||||
for matcher in self.service_uuid[service_uuid]
|
||||
if ble_device_matches(matcher, service_info)
|
||||
)
|
||||
@@ -310,7 +313,9 @@ class BluetoothCallbackMatcherIndex(
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the matcher index."""
|
||||
super().__init__()
|
||||
self.address: dict[str, list[BluetoothCallbackMatcherWithCallback]] = {}
|
||||
self.address: defaultdict[str, list[BluetoothCallbackMatcherWithCallback]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
self.connectable: list[BluetoothCallbackMatcherWithCallback] = []
|
||||
|
||||
def add_callback_matcher(
|
||||
@@ -323,7 +328,7 @@ class BluetoothCallbackMatcherIndex(
|
||||
We put them in the bucket that they are most likely to match.
|
||||
"""
|
||||
if ADDRESS in matcher:
|
||||
self.address.setdefault(matcher[ADDRESS], []).append(matcher)
|
||||
self.address[matcher[ADDRESS]].append(matcher)
|
||||
return
|
||||
|
||||
if super().add(matcher):
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bimmer-connected[china]==0.15.3"]
|
||||
"requirements": ["bimmer-connected[china]==0.16.1"]
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
"name": "Charging Mode",
|
||||
"state": {
|
||||
"immediate_charging": "Immediate charging",
|
||||
"delayed_charging": "Delayed charging"
|
||||
"delayed_charging": "Delayed charging",
|
||||
"no_action": "No action"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bring_api.bring import Bring
|
||||
from bring_api.exceptions import (
|
||||
from bring_api import (
|
||||
Bring,
|
||||
BringAuthException,
|
||||
BringParseException,
|
||||
BringRequestException,
|
||||
|
||||
@@ -6,9 +6,12 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bring_api.bring import Bring
|
||||
from bring_api.exceptions import BringAuthException, BringRequestException
|
||||
from bring_api.types import BringAuthResponse
|
||||
from bring_api import (
|
||||
Bring,
|
||||
BringAuthException,
|
||||
BringAuthResponse,
|
||||
BringRequestException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from bring_api.bring import Bring
|
||||
from bring_api.exceptions import (
|
||||
from bring_api import (
|
||||
Bring,
|
||||
BringAuthException,
|
||||
BringParseException,
|
||||
BringRequestException,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bring",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["bring-api==0.7.1"]
|
||||
"requirements": ["bring-api==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
|
||||
from bring_api.exceptions import BringRequestException
|
||||
from bring_api.types import BringItem, BringItemOperation, BringNotificationType
|
||||
from bring_api import (
|
||||
BringItem,
|
||||
BringItemOperation,
|
||||
BringNotificationType,
|
||||
BringRequestException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
|
||||
84
homeassistant/components/bryant_evolution/__init__.py
Normal file
84
homeassistant/components/bryant_evolution/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""The Bryant Evolution integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from evolutionhttp import BryantEvolutionLocalClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_FILENAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import names
|
||||
from .const import CONF_SYSTEM_ZONE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
|
||||
type BryantEvolutionLocalClients = dict[tuple[int, int], BryantEvolutionLocalClient]
|
||||
type BryantEvolutionConfigEntry = ConfigEntry[BryantEvolutionLocalClients]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _can_reach_device(client: BryantEvolutionLocalClient) -> bool:
|
||||
"""Return whether we can reach the device at the given filename."""
|
||||
# Verify that we can read current temperature to check that the
|
||||
# (filename, system, zone) is valid.
|
||||
return await client.read_current_temperature() is not None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Bryant Evolution from a config entry."""
|
||||
|
||||
# Add a device for the SAM itself.
|
||||
sam_uid = names.sam_device_uid(entry)
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, sam_uid)},
|
||||
manufacturer="Bryant",
|
||||
name="System Access Module",
|
||||
)
|
||||
|
||||
# Add a device for each system.
|
||||
for sys_id in (1, 2):
|
||||
if not any(sz[0] == sys_id for sz in entry.data[CONF_SYSTEM_ZONE]):
|
||||
_LOGGER.debug(
|
||||
"Skipping system %s because it is not configured for this integration: %s",
|
||||
sys_id,
|
||||
entry.data[CONF_SYSTEM_ZONE],
|
||||
)
|
||||
continue
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, names.system_device_uid(sam_uid, sys_id))},
|
||||
via_device=(DOMAIN, names.sam_device_uid(entry)),
|
||||
manufacturer="Bryant",
|
||||
name=f"System {sys_id}",
|
||||
)
|
||||
|
||||
# Create a client for every zone.
|
||||
entry.runtime_data = {}
|
||||
for sz in entry.data[CONF_SYSTEM_ZONE]:
|
||||
try:
|
||||
client = await BryantEvolutionLocalClient.get_client(
|
||||
sz[0], sz[1], entry.data[CONF_FILENAME]
|
||||
)
|
||||
if not await _can_reach_device(client):
|
||||
raise ConfigEntryNotReady
|
||||
entry.runtime_data[tuple(sz)] = client
|
||||
except FileNotFoundError as f:
|
||||
raise ConfigEntryNotReady from f
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
252
homeassistant/components/bryant_evolution/climate.py
Normal file
252
homeassistant/components/bryant_evolution/climate.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Support for Bryant Evolution HVAC systems."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from evolutionhttp import BryantEvolutionLocalClient
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BryantEvolutionConfigEntry, names
|
||||
from .const import CONF_SYSTEM_ZONE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BryantEvolutionConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a config entry."""
|
||||
|
||||
# Add a climate entity for each system/zone.
|
||||
sam_uid = names.sam_device_uid(config_entry)
|
||||
entities: list[Entity] = []
|
||||
for sz in config_entry.data[CONF_SYSTEM_ZONE]:
|
||||
system_id = sz[0]
|
||||
zone_id = sz[1]
|
||||
client = config_entry.runtime_data.get(tuple(sz))
|
||||
climate = BryantEvolutionClimate(
|
||||
client,
|
||||
system_id,
|
||||
zone_id,
|
||||
sam_uid,
|
||||
)
|
||||
entities.append(climate)
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BryantEvolutionClimate(ClimateEntity):
|
||||
"""ClimateEntity for Bryant Evolution HVAC systems.
|
||||
|
||||
Design note: this class updates using polling. However, polling
|
||||
is very slow (~1500 ms / parameter). To improve the user
|
||||
experience on updates, we also locally update this instance and
|
||||
call async_write_ha_state as well.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
_attr_hvac_modes = [
|
||||
HVACMode.HEAT,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT_COOL,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
_attr_fan_modes = ["auto", "low", "med", "high"]
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: BryantEvolutionLocalClient,
|
||||
system_id: int,
|
||||
zone_id: int,
|
||||
sam_uid: str,
|
||||
) -> None:
|
||||
"""Initialize an entity from parts."""
|
||||
self._client = client
|
||||
self._attr_name = None
|
||||
self._attr_unique_id = names.zone_entity_uid(sam_uid, system_id, zone_id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
manufacturer="Bryant",
|
||||
via_device=(DOMAIN, names.system_device_uid(sam_uid, system_id)),
|
||||
name=f"System {system_id} Zone {zone_id}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
self._attr_current_temperature = await self._client.read_current_temperature()
|
||||
if (fan_mode := await self._client.read_fan_mode()) is not None:
|
||||
self._attr_fan_mode = fan_mode.lower()
|
||||
else:
|
||||
self._attr_fan_mode = None
|
||||
self._attr_target_temperature = None
|
||||
self._attr_target_temperature_high = None
|
||||
self._attr_target_temperature_low = None
|
||||
self._attr_hvac_mode = await self._read_hvac_mode()
|
||||
|
||||
# Set target_temperature or target_temperature_{high, low} based on mode.
|
||||
match self._attr_hvac_mode:
|
||||
case HVACMode.HEAT:
|
||||
self._attr_target_temperature = (
|
||||
await self._client.read_heating_setpoint()
|
||||
)
|
||||
case HVACMode.COOL:
|
||||
self._attr_target_temperature = (
|
||||
await self._client.read_cooling_setpoint()
|
||||
)
|
||||
case HVACMode.HEAT_COOL:
|
||||
self._attr_target_temperature_high = (
|
||||
await self._client.read_cooling_setpoint()
|
||||
)
|
||||
self._attr_target_temperature_low = (
|
||||
await self._client.read_heating_setpoint()
|
||||
)
|
||||
case HVACMode.OFF:
|
||||
pass
|
||||
case _:
|
||||
_LOGGER.error("Unknown HVAC mode %s", self._attr_hvac_mode)
|
||||
|
||||
# Note: depends on current temperature and target temperature low read
|
||||
# above.
|
||||
self._attr_hvac_action = await self._read_hvac_action()
|
||||
|
||||
async def _read_hvac_mode(self) -> HVACMode:
|
||||
mode_and_active = await self._client.read_hvac_mode()
|
||||
if not mode_and_active:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_mode"
|
||||
)
|
||||
mode = mode_and_active[0]
|
||||
mode_enum = {
|
||||
"HEAT": HVACMode.HEAT,
|
||||
"COOL": HVACMode.COOL,
|
||||
"AUTO": HVACMode.HEAT_COOL,
|
||||
"OFF": HVACMode.OFF,
|
||||
}.get(mode.upper())
|
||||
if mode_enum is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_parse_hvac_mode",
|
||||
translation_placeholders={"mode": mode},
|
||||
)
|
||||
return mode_enum
|
||||
|
||||
async def _read_hvac_action(self) -> HVACAction:
|
||||
"""Return the current running hvac operation."""
|
||||
mode_and_active = await self._client.read_hvac_mode()
|
||||
if not mode_and_active:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_action"
|
||||
)
|
||||
mode, is_active = mode_and_active
|
||||
if not is_active:
|
||||
return HVACAction.OFF
|
||||
match mode.upper():
|
||||
case "HEAT":
|
||||
return HVACAction.HEATING
|
||||
case "COOL":
|
||||
return HVACAction.COOLING
|
||||
case "OFF":
|
||||
return HVACAction.OFF
|
||||
case "AUTO":
|
||||
# In AUTO, we need to figure out what the actual action is
|
||||
# based on the setpoints.
|
||||
if (
|
||||
self.current_temperature is not None
|
||||
and self.target_temperature_low is not None
|
||||
):
|
||||
if self.current_temperature > self.target_temperature_low:
|
||||
# If the system is on and the current temperature is
|
||||
# higher than the point at which heating would activate,
|
||||
# then we must be cooling.
|
||||
return HVACAction.COOLING
|
||||
return HVACAction.HEATING
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_parse_hvac_mode",
|
||||
translation_placeholders={
|
||||
"mode_and_active": mode_and_active,
|
||||
"current_temperature": str(self.current_temperature),
|
||||
"target_temperature_low": str(self.target_temperature_low),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode == HVACMode.HEAT_COOL:
|
||||
hvac_mode = HVACMode.AUTO
|
||||
if not await self._client.set_hvac_mode(hvac_mode):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_hvac_mode"
|
||||
)
|
||||
self._attr_hvac_mode = hvac_mode
|
||||
self._async_write_ha_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if kwargs.get("target_temp_high"):
|
||||
temp = int(kwargs["target_temp_high"])
|
||||
if not await self._client.set_cooling_setpoint(temp):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
|
||||
)
|
||||
self._attr_target_temperature_high = temp
|
||||
|
||||
if kwargs.get("target_temp_low"):
|
||||
temp = int(kwargs["target_temp_low"])
|
||||
if not await self._client.set_heating_setpoint(temp):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
|
||||
)
|
||||
self._attr_target_temperature_low = temp
|
||||
|
||||
if kwargs.get("temperature"):
|
||||
temp = int(kwargs["temperature"])
|
||||
fn = (
|
||||
self._client.set_heating_setpoint
|
||||
if self.hvac_mode == HVACMode.HEAT
|
||||
else self._client.set_cooling_setpoint
|
||||
)
|
||||
if not await fn(temp):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_temp"
|
||||
)
|
||||
self._attr_target_temperature = temp
|
||||
|
||||
# If we get here, we must have changed something unless HA allowed an
|
||||
# invalid service call (without any recognized kwarg).
|
||||
self._async_write_ha_state()
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
if not await self._client.set_fan_mode(fan_mode):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_fan_mode"
|
||||
)
|
||||
self._attr_fan_mode = fan_mode.lower()
|
||||
self.async_write_ha_state()
|
||||
87
homeassistant/components/bryant_evolution/config_flow.py
Normal file
87
homeassistant/components/bryant_evolution/config_flow.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Config flow for Bryant Evolution integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from evolutionhttp import BryantEvolutionLocalClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_FILENAME
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
|
||||
from .const import CONF_SYSTEM_ZONE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_FILENAME, default="/dev/ttyUSB0"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _enumerate_sz(tty: str) -> list[tuple[int, int]]:
|
||||
"""Return (system, zone) tuples for each system+zone accessible through tty."""
|
||||
return [
|
||||
(system_id, zone.zone_id)
|
||||
for system_id in (1, 2)
|
||||
for zone in await BryantEvolutionLocalClient.enumerate_zones(system_id, tty)
|
||||
]
|
||||
|
||||
|
||||
class BryantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Bryant Evolution."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
|
||||
except FileNotFoundError:
|
||||
_LOGGER.error("Could not open %s: not found", user_input[CONF_FILENAME])
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
if len(system_zone) != 0:
|
||||
return self.async_create_entry(
|
||||
title=f"SAM at {user_input[CONF_FILENAME]}",
|
||||
data={
|
||||
CONF_FILENAME: user_input[CONF_FILENAME],
|
||||
CONF_SYSTEM_ZONE: system_zone,
|
||||
},
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle integration reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
|
||||
if len(system_zone) != 0:
|
||||
our_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
assert our_entry is not None, "Could not find own entry"
|
||||
return self.async_update_reload_and_abort(
|
||||
entry=our_entry,
|
||||
data={
|
||||
CONF_FILENAME: user_input[CONF_FILENAME],
|
||||
CONF_SYSTEM_ZONE: system_zone,
|
||||
},
|
||||
unique_id=UNDEFINED,
|
||||
reason="reconfigured",
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
4
homeassistant/components/bryant_evolution/const.py
Normal file
4
homeassistant/components/bryant_evolution/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the Bryant Evolution integration."""
|
||||
|
||||
DOMAIN = "bryant_evolution"
|
||||
CONF_SYSTEM_ZONE = "system_zone"
|
||||
10
homeassistant/components/bryant_evolution/manifest.json
Normal file
10
homeassistant/components/bryant_evolution/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "bryant_evolution",
|
||||
"name": "Bryant Evolution",
|
||||
"codeowners": ["@danielsmyers"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["evolutionhttp==0.0.18"]
|
||||
}
|
||||
18
homeassistant/components/bryant_evolution/names.py
Normal file
18
homeassistant/components/bryant_evolution/names.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Functions to generate names for devices and entities."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
|
||||
def sam_device_uid(entry: ConfigEntry) -> str:
|
||||
"""Return the UID for the SAM device."""
|
||||
return entry.entry_id
|
||||
|
||||
|
||||
def system_device_uid(sam_uid: str, system_id: int) -> str:
|
||||
"""Return the UID for a given system (e.g., 1) under a SAM."""
|
||||
return f"{sam_uid}-S{system_id}"
|
||||
|
||||
|
||||
def zone_entity_uid(sam_uid: str, system_id: int, zone_id: int) -> str:
|
||||
"""Return the UID for a given system and zone (e.g., 1 and 2) under a SAM."""
|
||||
return f"{sam_uid}-S{system_id}-Z{zone_id}"
|
||||
48
homeassistant/components/bryant_evolution/strings.json
Normal file
48
homeassistant/components/bryant_evolution/strings.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"filename": "Serial port filename"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"failed_to_read_hvac_mode": {
|
||||
"message": "Failed to read current HVAC mode"
|
||||
},
|
||||
"failed_to_parse_hvac_mode": {
|
||||
"message": "Cannot parse response to HVACMode: {mode}"
|
||||
},
|
||||
"failed_to_read_hvac_action": {
|
||||
"message": "Failed to read current HVAC action"
|
||||
},
|
||||
"failed_to_parse_hvac_action": {
|
||||
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
|
||||
},
|
||||
"failed_to_set_hvac_mode": {
|
||||
"message": "Failed to set HVAC mode"
|
||||
},
|
||||
"failed_to_set_clsp": {
|
||||
"message": "Failed to set cooling setpoint"
|
||||
},
|
||||
"failed_to_set_htsp": {
|
||||
"message": "Failed to set heating setpoint"
|
||||
},
|
||||
"failed_to_set_temp": {
|
||||
"message": "Failed to set temperature"
|
||||
},
|
||||
"failed_to_set_fan_mode": {
|
||||
"message": "Failed to set fan mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -914,12 +914,37 @@ async def async_service_temperature_set(
|
||||
"""Handle set temperature service."""
|
||||
hass = entity.hass
|
||||
kwargs = {}
|
||||
min_temp = entity.min_temp
|
||||
max_temp = entity.max_temp
|
||||
temp_unit = entity.temperature_unit
|
||||
|
||||
for value, temp in service_call.data.items():
|
||||
if value in CONVERTIBLE_ATTRIBUTE:
|
||||
kwargs[value] = TemperatureConverter.convert(
|
||||
temp, hass.config.units.temperature_unit, entity.temperature_unit
|
||||
kwargs[value] = check_temp = TemperatureConverter.convert(
|
||||
temp, hass.config.units.temperature_unit, temp_unit
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Check valid temperature %d %s (%d %s) in range %d %s - %d %s",
|
||||
check_temp,
|
||||
entity.temperature_unit,
|
||||
temp,
|
||||
hass.config.units.temperature_unit,
|
||||
min_temp,
|
||||
temp_unit,
|
||||
max_temp,
|
||||
temp_unit,
|
||||
)
|
||||
if check_temp < min_temp or check_temp > max_temp:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="temp_out_of_range",
|
||||
translation_placeholders={
|
||||
"check_temp": str(check_temp),
|
||||
"min_temp": str(min_temp),
|
||||
"max_temp": str(max_temp),
|
||||
},
|
||||
)
|
||||
else:
|
||||
kwargs[value] = temp
|
||||
|
||||
|
||||
@@ -266,6 +266,9 @@
|
||||
},
|
||||
"not_valid_fan_mode": {
|
||||
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
|
||||
},
|
||||
"temp_out_of_range": {
|
||||
"message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from hass_nabucasa.voice import MAP_VOICE
|
||||
from hass_nabucasa.voice import MAP_VOICE, Gender
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import User
|
||||
@@ -91,8 +91,8 @@ class CloudPreferencesStore(Store):
|
||||
# The new second item is the voice name.
|
||||
default_tts_voice = old_data.get(PREF_TTS_DEFAULT_VOICE)
|
||||
if default_tts_voice and (voice_item_two := default_tts_voice[1]) in (
|
||||
"female",
|
||||
"male",
|
||||
Gender.FEMALE,
|
||||
Gender.MALE,
|
||||
):
|
||||
language: str = default_tts_voice[0]
|
||||
if voice := MAP_VOICE.get((language, voice_item_two)):
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
@@ -76,7 +76,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ccb.connect()
|
||||
|
||||
# Schedule disconnect on shutdown
|
||||
def _shutdown(_event):
|
||||
def _shutdown(_event: Event) -> None:
|
||||
ccb.disconnect()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||
@@ -90,7 +90,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
class ComfoConnectBridge:
|
||||
"""Representation of a ComfoConnect bridge."""
|
||||
|
||||
def __init__(self, hass, bridge, name, token, friendly_name, pin):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
bridge: Bridge,
|
||||
name: str,
|
||||
token: str,
|
||||
friendly_name: str,
|
||||
pin: int,
|
||||
) -> None:
|
||||
"""Initialize the ComfoConnect bridge."""
|
||||
self.name = name
|
||||
self.hass = hass
|
||||
@@ -104,17 +112,17 @@ class ComfoConnectBridge:
|
||||
)
|
||||
self.comfoconnect.callback_sensor = self.sensor_callback
|
||||
|
||||
def connect(self):
|
||||
def connect(self) -> None:
|
||||
"""Connect with the bridge."""
|
||||
_LOGGER.debug("Connecting with bridge")
|
||||
self.comfoconnect.connect(True)
|
||||
|
||||
def disconnect(self):
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the bridge."""
|
||||
_LOGGER.debug("Disconnecting from bridge")
|
||||
self.comfoconnect.disconnect()
|
||||
|
||||
def sensor_callback(self, var, value):
|
||||
def sensor_callback(self, var: str, value: str) -> None:
|
||||
"""Notify listeners that we have received an update."""
|
||||
_LOGGER.debug("Received update for %s: %s", var, value)
|
||||
dispatcher_send(
|
||||
|
||||
@@ -327,7 +327,7 @@ class ComfoConnectSensor(SensorEntity):
|
||||
self._ccb.comfoconnect.register_sensor, self.entity_description.sensor_id
|
||||
)
|
||||
|
||||
def _handle_update(self, value):
|
||||
def _handle_update(self, value: float) -> None:
|
||||
"""Handle update callbacks."""
|
||||
_LOGGER.debug(
|
||||
"Handle update for sensor %s (%d): %s",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.cover import CoverEntity
|
||||
from homeassistant.const import (
|
||||
@@ -145,8 +145,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity):
|
||||
if self._command_state:
|
||||
LOGGER.info("Running state value command: %s", self._command_state)
|
||||
return await async_check_output_or_log(self._command_state, self._timeout)
|
||||
if TYPE_CHECKING:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _update_entity_state(self, now: datetime | None = None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
|
||||
from homeassistant.const import (
|
||||
@@ -147,8 +147,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
|
||||
if self._value_template:
|
||||
return await self._async_query_state_value(self._command_state)
|
||||
return await self._async_query_state_code(self._command_state)
|
||||
if TYPE_CHECKING:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _update_entity_state(self, now: datetime | None = None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.scene import (
|
||||
)
|
||||
from homeassistant.config import SCENE_CONFIG_PATH
|
||||
from homeassistant.const import CONF_ID, SERVICE_RELOAD
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
|
||||
from .const import ACTION_DELETE
|
||||
@@ -32,7 +32,9 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key)
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
DOMAIN, HOMEASSISTANT_DOMAIN, config_key
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
@@ -47,6 +47,7 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
@@ -348,6 +349,16 @@ class DefaultAgent(ConversationEntity):
|
||||
}
|
||||
for entity in result.entities_list
|
||||
}
|
||||
async_conversation_trace_append(
|
||||
ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
"intent_name": result.intent.name,
|
||||
"slots": {
|
||||
entity.name: entity.value or entity.text
|
||||
for entity in result.entities_list
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
intent_response = await intent.async_handle(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.10"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.29"]
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ class ConversationTraceEventType(enum.StrEnum):
|
||||
AGENT_DETAIL = "agent_detail"
|
||||
"""Event detail added by a conversation agent."""
|
||||
|
||||
LLM_TOOL_CALL = "llm_tool_call"
|
||||
"""An LLM Tool call"""
|
||||
TOOL_CALL = "tool_call"
|
||||
"""A conversation agent Tool call or default agent intent call."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
|
||||
from . import DOMAIN
|
||||
from . import DOMAIN, CoverDeviceClass
|
||||
|
||||
INTENT_OPEN_COVER = "HassOpenCover"
|
||||
INTENT_CLOSE_COVER = "HassCloseCover"
|
||||
@@ -21,6 +21,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"Opening {}",
|
||||
description="Opens a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
),
|
||||
)
|
||||
intent.async_register(
|
||||
@@ -32,5 +33,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"Closing {}",
|
||||
description="Closes a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -162,6 +162,7 @@ class DdWrtDeviceScanner(DeviceScanner):
|
||||
)
|
||||
return None
|
||||
_LOGGER.error("Invalid response from DD-WRT: %s", response)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ddwrt_response(data_str):
|
||||
|
||||
@@ -66,7 +66,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
|
||||
def __init__(self, device: Light, hub: DeconzHub) -> None:
|
||||
"""Set up fan."""
|
||||
super().__init__(device, hub)
|
||||
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
if device.fan_speed in ORDERED_NAMED_FAN_SPEEDS:
|
||||
self._default_on_speed = device.fan_speed
|
||||
|
||||
@@ -96,7 +95,8 @@ class DeconzFan(DeconzDevice[Light], FanEntity):
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
if percentage == 0:
|
||||
return await self.async_turn_off()
|
||||
await self.async_turn_off()
|
||||
return
|
||||
await self.hub.api.lights.lights.set_state(
|
||||
id=self._device.resource_id,
|
||||
fan_speed=percentage_to_ordered_list_item(
|
||||
|
||||
@@ -253,11 +253,12 @@ class DenonDevice(MediaPlayerEntity):
|
||||
return SUPPORT_DENON
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
def source(self) -> str | None:
|
||||
"""Return the current input source."""
|
||||
for pretty_name, name in self._source_list.items():
|
||||
if self._mediasource == name:
|
||||
return pretty_name
|
||||
return None
|
||||
|
||||
def turn_off(self) -> None:
|
||||
"""Turn off media player."""
|
||||
|
||||
@@ -21,6 +21,8 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool:
|
||||
"""Check, if device is attached to the router."""
|
||||
|
||||
@@ -22,6 +22,8 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
|
||||
from .entity import DevoloEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DevoloButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
@@ -22,6 +22,8 @@ from homeassistant.helpers.update_coordinator import (
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -20,6 +20,8 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DevoloImageEntityDescription(ImageEntityDescription):
|
||||
|
||||
@@ -31,6 +31,8 @@ from .const import (
|
||||
)
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_CoordinatorDataT = TypeVar(
|
||||
"_CoordinatorDataT",
|
||||
bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo],
|
||||
|
||||
@@ -21,6 +21,8 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, REGULAR_FIRMWARE
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DevoloUpdateEntityDescription(UpdateEntityDescription):
|
||||
|
||||
@@ -103,6 +103,8 @@ def get_api_version(message):
|
||||
if message.get("responseId") is not None:
|
||||
return V2
|
||||
|
||||
raise ValueError(f"Unable to extract API version from message: {message}")
|
||||
|
||||
|
||||
async def async_handle_message(hass, message):
|
||||
"""Handle a DialogFlow message."""
|
||||
@@ -173,3 +175,5 @@ class DialogflowResponse:
|
||||
|
||||
if self.api_version is V2:
|
||||
return {"fulfillmentText": self.speech, "source": SOURCE}
|
||||
|
||||
raise ValueError(f"Invalid API version: {self.api_version}")
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from doorbirdpy import DoorBird
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -30,6 +31,8 @@ CONF_CUSTOM_URL = "hass_url_override"
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the DoorBird component."""
|
||||
@@ -68,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
|
||||
door_bird_data = DoorBirdData(door_station, info, event_entity_ids)
|
||||
door_station.update_events(events)
|
||||
# Subscribe to doorbell or motion events
|
||||
if not await _async_register_events(hass, door_station):
|
||||
if not await _async_register_events(hass, door_station, entry):
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
||||
@@ -84,24 +87,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
|
||||
|
||||
|
||||
async def _async_register_events(
|
||||
hass: HomeAssistant, door_station: ConfiguredDoorBird
|
||||
hass: HomeAssistant, door_station: ConfiguredDoorBird, entry: DoorBirdConfigEntry
|
||||
) -> bool:
|
||||
"""Register events on device."""
|
||||
issue_id = f"doorbird_schedule_error_{entry.entry_id}"
|
||||
try:
|
||||
await door_station.async_register_events()
|
||||
except ClientResponseError:
|
||||
persistent_notification.async_create(
|
||||
except ClientResponseError as ex:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
(
|
||||
"Doorbird configuration failed. Please verify that API "
|
||||
"Operator permission is enabled for the Doorbird user. "
|
||||
"A restart will be required once permissions have been "
|
||||
"verified."
|
||||
),
|
||||
title="Doorbird Configuration Failure",
|
||||
notification_id="doorbird_schedule_error",
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="error_registering_events",
|
||||
data={"entry_id": entry.entry_id},
|
||||
is_fixable=True,
|
||||
translation_placeholders={
|
||||
"error": str(ex),
|
||||
"name": door_station.name or entry.data[CONF_NAME],
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("Error registering DoorBird events", exc_info=True)
|
||||
return False
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
return True
|
||||
|
||||
@@ -111,4 +120,4 @@ async def _update_listener(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> N
|
||||
door_station = entry.runtime_data.door_station
|
||||
door_station.update_events(entry.options[CONF_EVENTS])
|
||||
# Subscribe to doorbell or motion events
|
||||
await _async_register_events(hass, door_station)
|
||||
await _async_register_events(hass, door_station, entry)
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from doorbirdpy import (
|
||||
DoorBird,
|
||||
DoorBirdScheduleEntry,
|
||||
@@ -103,9 +105,8 @@ class ConfiguredDoorBird:
|
||||
async def async_register_events(self) -> None:
|
||||
"""Register events on device."""
|
||||
if not self.door_station_events:
|
||||
# User may not have permission to get the favorites
|
||||
# The config entry might not have any events configured yet
|
||||
return
|
||||
|
||||
http_fav = await self._async_register_events()
|
||||
event_config = await self._async_get_event_config(http_fav)
|
||||
_LOGGER.debug("%s: Event config: %s", self.name, event_config)
|
||||
@@ -171,15 +172,21 @@ class ConfiguredDoorBird:
|
||||
) -> DoorbirdEventConfig:
|
||||
"""Get events and unconfigured favorites from http favorites."""
|
||||
device = self.device
|
||||
schedule = await device.schedule()
|
||||
events: list[DoorbirdEvent] = []
|
||||
unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
|
||||
try:
|
||||
schedule = await device.schedule()
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.NOT_FOUND:
|
||||
# D301 models do not support schedules
|
||||
return DoorbirdEventConfig(events, [], unconfigured_favorites)
|
||||
raise
|
||||
favorite_input_type = {
|
||||
output.param: entry.input
|
||||
for entry in schedule
|
||||
for output in entry.output
|
||||
if output.event == HTTP_EVENT_TYPE
|
||||
}
|
||||
events: list[DoorbirdEvent] = []
|
||||
unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
|
||||
default_event_types = {
|
||||
self._get_event_name(event): event_type
|
||||
for event, event_type in DEFAULT_EVENT_TYPES
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "DoorBird",
|
||||
"codeowners": ["@oblogic7", "@bdraco", "@flacjacket"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"dependencies": ["http", "repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbird",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
|
||||
55
homeassistant/components/doorbird/repairs.py
Normal file
55
homeassistant/components/doorbird/repairs.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Repairs for DoorBird."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
|
||||
class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
"""Handler to show doorbird error and reload."""
|
||||
|
||||
def __init__(self, entry_id: str) -> None:
|
||||
"""Initialize the flow."""
|
||||
self.entry_id = entry_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
description_placeholders = None
|
||||
if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
|
||||
description_placeholders = issue.translation_placeholders
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
assert data is not None
|
||||
entry_id = data["entry_id"]
|
||||
assert isinstance(entry_id, str)
|
||||
return DoorBirdReloadConfirmRepairFlow(entry_id=entry_id)
|
||||
@@ -11,6 +11,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"error_registering_events": {
|
||||
"title": "DoorBird {name} configuration failure",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::doorbird::issues::error_registering_events::title%]",
|
||||
"description": "Configuring DoorBird {name} failed with error: `{error}`. Please enable the API Operator permission for the DoorBird user and continue to reload the integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -25,19 +25,12 @@ class DoorBirdRequestView(HomeAssistantView):
|
||||
"""Respond to requests from the device."""
|
||||
hass = request.app[KEY_HASS]
|
||||
token: str | None = request.query.get("token")
|
||||
if (
|
||||
token is None
|
||||
or (door_station := get_door_station_by_token(hass, token)) is None
|
||||
):
|
||||
if not token or not (door_station := get_door_station_by_token(hass, token)):
|
||||
return web.Response(
|
||||
status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided."
|
||||
)
|
||||
|
||||
if door_station:
|
||||
event_data = door_station.get_event_data(event)
|
||||
else:
|
||||
event_data = {}
|
||||
|
||||
event_data = door_station.get_event_data(event)
|
||||
#
|
||||
# This integration uses a multiple different events.
|
||||
# It would be a major breaking change to change this to
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.3.1"]
|
||||
"requirements": ["dsmr-parser==1.4.2"]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
|
||||
from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader
|
||||
@@ -15,7 +16,7 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
create_rfxtrx_dsmr_reader,
|
||||
create_rfxtrx_tcp_dsmr_reader,
|
||||
)
|
||||
from dsmr_parser.objects import DSMRObject, Telegram
|
||||
from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
|
||||
import serial
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -77,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
|
||||
obis_reference: str
|
||||
|
||||
|
||||
class MbusDeviceType(IntEnum):
|
||||
"""Types of mbus devices (13757-3:2013)."""
|
||||
|
||||
GAS = 3
|
||||
WATER = 7
|
||||
|
||||
|
||||
SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="timestamp",
|
||||
@@ -318,7 +326,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
DSMRSensorEntityDescription(
|
||||
key="belgium_max_current_per_phase",
|
||||
translation_key="max_current_per_phase",
|
||||
obis_reference="BELGIUM_MAX_CURRENT_PER_PHASE",
|
||||
obis_reference="FUSE_THRESHOLD_L1",
|
||||
dsmr_versions={"5B"},
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -377,38 +385,36 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_mbus_entity(
|
||||
mbus: int, mtype: int, telegram: Telegram
|
||||
) -> DSMRSensorEntityDescription | None:
|
||||
"""Create a new MBUS Entity."""
|
||||
if mtype == 3 and hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING2"):
|
||||
return DSMRSensorEntityDescription(
|
||||
key=f"mbus{mbus}_gas_reading",
|
||||
SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = {
|
||||
MbusDeviceType.GAS: (
|
||||
DSMRSensorEntityDescription(
|
||||
key="gas_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING2",
|
||||
obis_reference="MBUS_METER_READING",
|
||||
is_gas=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
if mtype == 7 and (hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING1")):
|
||||
return DSMRSensorEntityDescription(
|
||||
key=f"mbus{mbus}_water_reading",
|
||||
),
|
||||
),
|
||||
MbusDeviceType.WATER: (
|
||||
DSMRSensorEntityDescription(
|
||||
key="water_reading",
|
||||
translation_key="water_meter_reading",
|
||||
obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING1",
|
||||
obis_reference="MBUS_METER_READING",
|
||||
is_water=True,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
return None
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def device_class_and_uom(
|
||||
telegram: dict[str, DSMRObject],
|
||||
data: Telegram | MbusDevice,
|
||||
entity_description: DSMRSensorEntityDescription,
|
||||
) -> tuple[SensorDeviceClass | None, str | None]:
|
||||
"""Get native unit of measurement from telegram,."""
|
||||
dsmr_object = getattr(telegram, entity_description.obis_reference)
|
||||
dsmr_object = getattr(data, entity_description.obis_reference)
|
||||
uom: str | None = getattr(dsmr_object, "unit") or None
|
||||
with suppress(ValueError):
|
||||
if entity_description.device_class == SensorDeviceClass.GAS and (
|
||||
@@ -433,7 +439,9 @@ def rename_old_gas_to_mbus(
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
|
||||
for entity in entries:
|
||||
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
|
||||
if entity.unique_id.endswith(
|
||||
"belgium_5min_gas_meter_reading"
|
||||
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
@@ -460,37 +468,60 @@ def rename_old_gas_to_mbus(
|
||||
dev_reg.async_remove_device(device_id)
|
||||
|
||||
|
||||
def is_supported_description(
|
||||
data: Telegram | MbusDevice,
|
||||
description: DSMRSensorEntityDescription,
|
||||
dsmr_version: str,
|
||||
) -> bool:
|
||||
"""Check if this is a supported description for this telegram."""
|
||||
return hasattr(data, description.obis_reference) and (
|
||||
description.dsmr_versions is None or dsmr_version in description.dsmr_versions
|
||||
)
|
||||
|
||||
|
||||
def create_mbus_entities(
|
||||
hass: HomeAssistant, telegram: Telegram, entry: ConfigEntry
|
||||
) -> list[DSMREntity]:
|
||||
hass: HomeAssistant, telegram: Telegram, entry: ConfigEntry, dsmr_version: str
|
||||
) -> Generator[DSMREntity]:
|
||||
"""Create MBUS Entities."""
|
||||
entities = []
|
||||
for idx in range(1, 5):
|
||||
if (
|
||||
device_type := getattr(telegram, f"BELGIUM_MBUS{idx}_DEVICE_TYPE", None)
|
||||
) is None:
|
||||
mbus_devices: list[MbusDevice] = getattr(telegram, "MBUS_DEVICES", [])
|
||||
for device in mbus_devices:
|
||||
if (device_type := getattr(device, "MBUS_DEVICE_TYPE", None)) is None:
|
||||
continue
|
||||
if (type_ := int(device_type.value)) not in (3, 7):
|
||||
continue
|
||||
if identifier := getattr(
|
||||
telegram, f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", None
|
||||
):
|
||||
type_ = int(device_type.value)
|
||||
|
||||
if identifier := getattr(device, "MBUS_EQUIPMENT_IDENTIFIER", None):
|
||||
serial_ = identifier.value
|
||||
rename_old_gas_to_mbus(hass, entry, serial_)
|
||||
else:
|
||||
serial_ = ""
|
||||
if description := create_mbus_entity(idx, type_, telegram):
|
||||
entities.append(
|
||||
DSMREntity(
|
||||
description,
|
||||
entry,
|
||||
telegram,
|
||||
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
|
||||
serial_,
|
||||
idx,
|
||||
)
|
||||
|
||||
for description in SENSORS_MBUS_DEVICE_TYPE.get(type_, ()):
|
||||
if not is_supported_description(device, description, dsmr_version):
|
||||
continue
|
||||
yield DSMREntity(
|
||||
description,
|
||||
entry,
|
||||
telegram,
|
||||
*device_class_and_uom(device, description), # type: ignore[arg-type]
|
||||
serial_,
|
||||
device.channel_id,
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def get_dsmr_object(
|
||||
telegram: Telegram | None, mbus_id: int, obis_reference: str
|
||||
) -> DSMRObject | None:
|
||||
"""Extract DSMR object from telegram."""
|
||||
if not telegram:
|
||||
return None
|
||||
|
||||
telegram_or_device: Telegram | MbusDevice | None = telegram
|
||||
if mbus_id:
|
||||
telegram_or_device = telegram.get_mbus_device_by_channel(mbus_id)
|
||||
if telegram_or_device is None:
|
||||
return None
|
||||
|
||||
return getattr(telegram_or_device, obis_reference, None)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -510,8 +541,7 @@ async def async_setup_entry(
|
||||
add_entities_handler()
|
||||
add_entities_handler = None
|
||||
|
||||
if dsmr_version == "5B":
|
||||
entities.extend(create_mbus_entities(hass, telegram, entry))
|
||||
entities.extend(create_mbus_entities(hass, telegram, entry, dsmr_version))
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
@@ -522,12 +552,8 @@ async def async_setup_entry(
|
||||
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
|
||||
)
|
||||
for description in SENSORS
|
||||
if (
|
||||
description.dsmr_versions is None
|
||||
or dsmr_version in description.dsmr_versions
|
||||
)
|
||||
if is_supported_description(telegram, description, dsmr_version)
|
||||
and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data)
|
||||
and hasattr(telegram, description.obis_reference)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
@@ -723,6 +749,7 @@ class DSMREntity(SensorEntity):
|
||||
identifiers={(DOMAIN, device_serial)},
|
||||
name=device_name,
|
||||
)
|
||||
self._mbus_id = mbus_id
|
||||
if mbus_id != 0:
|
||||
if serial_id:
|
||||
self._attr_unique_id = f"{device_serial}"
|
||||
@@ -737,20 +764,22 @@ class DSMREntity(SensorEntity):
|
||||
self.telegram = telegram
|
||||
if self.hass and (
|
||||
telegram is None
|
||||
or hasattr(telegram, self.entity_description.obis_reference)
|
||||
or get_dsmr_object(
|
||||
telegram, self._mbus_id, self.entity_description.obis_reference
|
||||
)
|
||||
):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_dsmr_object_attr(self, attribute: str) -> str | None:
|
||||
"""Read attribute from last received telegram for this DSMR object."""
|
||||
# Make sure telegram contains an object for this entities obis
|
||||
if self.telegram is None or not hasattr(
|
||||
self.telegram, self.entity_description.obis_reference
|
||||
):
|
||||
# Get the object
|
||||
dsmr_object = get_dsmr_object(
|
||||
self.telegram, self._mbus_id, self.entity_description.obis_reference
|
||||
)
|
||||
if dsmr_object is None:
|
||||
return None
|
||||
|
||||
# Get the attribute value if the object has it
|
||||
dsmr_object = getattr(self.telegram, self.entity_description.obis_reference)
|
||||
attr: str | None = getattr(dsmr_object, attribute)
|
||||
return attr
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
@@ -102,7 +103,7 @@ class DublinPublicTransportSensor(SensorEntity):
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
if self._times is not None:
|
||||
next_up = "None"
|
||||
@@ -117,6 +118,7 @@ class DublinPublicTransportSensor(SensorEntity):
|
||||
ATTR_ROUTE: self._times[0][ATTR_ROUTE],
|
||||
ATTR_NEXT_UP: next_up,
|
||||
}
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
|
||||
@@ -46,7 +46,7 @@ class EcobeeBinarySensor(BinarySensorEntity):
|
||||
self.index = sensor_index
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return a unique identifier for this sensor."""
|
||||
for sensor in self.data.ecobee.get_remote_sensors(self.index):
|
||||
if sensor["name"] == self.sensor_name:
|
||||
@@ -54,6 +54,7 @@ class EcobeeBinarySensor(BinarySensorEntity):
|
||||
return f"{sensor['code']}-{self.device_class}"
|
||||
thermostat = self.data.ecobee.get_thermostat(self.index)
|
||||
return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user