Compare commits

..

244 Commits

Author SHA1 Message Date
Joostlek
f2a3fd169b Fix 2025-11-13 17:54:30 +01:00
Joostlek
bff162255d Merge branch 'dev' into flussButtonApi 2025-11-13 17:48:31 +01:00
Åke Strandberg
0c45b7f615 Add reconfiguration flow to senz (#156539) 2025-11-13 16:49:16 +01:00
Alexandre CUER
bfa1116115 Add quality scale to Emoncms (#149727)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 16:10:29 +01:00
Arie Catsman
4984237987 Add alternative ct meter source to enphase_envoy diagnostics (#154468) 2025-11-13 15:37:36 +01:00
Joost Lekkerkerker
3839573151 Bump pySmartThings to 3.3.3 (#156528) 2025-11-13 15:31:03 +01:00
Åke Strandberg
e02dc53df3 Add reauthentication flow and tests to senz (#156534) 2025-11-13 15:28:45 +01:00
Arie Catsman
bedae1e12c Optimize Enphase_Envoy CT sensor entity code (#153859) 2025-11-13 14:59:24 +01:00
epenet
b4eb73be98 Improve tests for Tuya alarm control panel (#156481) 2025-11-13 14:44:38 +01:00
wollew
0ac3f776fa set shorthand atrributes for supported_features in velux cover (#156524) 2025-11-13 14:18:20 +01:00
Petar Petrov
8e8a4fff11 Extract grid, gas, and water source validation into separate functions (#156515) 2025-11-13 13:28:25 +01:00
Åke Strandberg
579ffcc64d Add unique_id to senz config_entry (#156472) 2025-11-13 12:26:33 +01:00
Foscam-wangzhengyu
81943fb31d URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 12:23:28 +01:00
Petro31
70dd0bf12e Modernize template alarm control panel (#156476) 2025-11-13 12:21:03 +01:00
Tom Matheussen
c2d462c1e7 Refactor Satel Integra platforms to use shared base entity (#156499) 2025-11-13 12:20:32 +01:00
epenet
49e050cc60 Redact more DP codes in tuya diagnostics (#156497) 2025-11-13 12:18:43 +01:00
Josef Zweck
f6d829a2f3 Bump pylamarzocco to 2.1.3 (#156501) 2025-11-13 11:54:15 +01:00
Aarni Koskela
e44e3b6f25 Rename RuuviTag BLE to Ruuvi BLE (#156504) 2025-11-13 11:36:50 +01:00
Christopher Fenner
af603661c0 Fix spelling in ViCare integration (#156500) 2025-11-13 10:54:55 +01:00
puddly
35c6113777 Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-13 09:25:00 +01:00
TheJulianJES
3c2f729ddc Fix Z-Wave generating name before setting entity description (#156494) 2025-11-13 08:18:22 +01:00
Erik Montnemery
0d63cb765f Fix lg_netcast tests opening sockets (#156459)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 07:44:56 +01:00
TheJulianJES
3cb414511b Migrate Z-Wave event entity to new discovery schema (#156320) 2025-11-13 07:22:37 +01:00
karwosts
f55c36d42d Update ical to 11.1.0 (#156487) 2025-11-12 20:24:04 -08:00
Erik Montnemery
26bb301cc0 Fix lifx tests opening sockets (#156460) 2025-11-12 21:51:54 +02:00
Erik Montnemery
4159e483ee Fix wiz tests opening sockets (#156468) 2025-11-12 20:11:15 +01:00
Erik Montnemery
7eb6f7cc07 Fix romy tests opening sockets (#156466) 2025-11-12 20:10:46 +01:00
epenet
a7d01b0b03 Use json_loads_object in tuya models (#156455) 2025-11-12 20:08:28 +01:00
epenet
1e5cfddf83 Use json_loads_object in Tuya light (#156452) 2025-11-12 19:34:17 +01:00
epenet
006fc5b10a Remove JSON parsing from tuya diagnostics (#156451) 2025-11-12 19:32:40 +01:00
Erik Montnemery
35a4b685b3 Fix steamist tests opening sockets (#156467) 2025-11-12 12:01:21 -06:00
Janez Urevc
b166818ef4 Bump tesla-wall-connector to 1.1.0 (#156438) 2025-11-12 17:45:08 +01:00
Erik Montnemery
34cd9f11d0 Fix onkyo tests opening sockets (#156461) 2025-11-12 17:32:58 +01:00
Erik Montnemery
0711d62085 Change collation to utf8mb4_bin for MySQL and MariaDB databases (#156297)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-12 16:54:58 +01:00
J. Diego Rodríguez Royo
f70aeafb5f Bump aiohomeconnect to version 0.23.1 (#156454) 2025-11-12 15:59:20 +01:00
MoonDevLT
e2279b3589 Bump lunatone-rest-api-client to 0.5.7 (#156356) 2025-11-12 14:44:52 +01:00
Christopher Fenner
87b68e99ec Add compressor, condensor and evaporator sensors in ViCare integration (#156411) 2025-11-12 14:42:26 +01:00
Manu
b6c8b787e8 Add device storage sensor entities to Xbox (#155657) 2025-11-12 13:53:42 +01:00
Franck Nijhof
78f26edc29 Extend base jinja2 extension with limited template errors (#156431) 2025-11-12 13:52:15 +01:00
ehendrix23
5e6a72de90 Bump pyecobee to 0.3.2 (#156421)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 13:40:08 +01:00
Erik Montnemery
dcc559f8b6 Fix progress step bugs (#155923) 2025-11-12 13:14:53 +01:00
Manu
eda49cced0 Code quality improvements for Xbox integration (#156395) 2025-11-12 14:09:53 +02:00
Josef Zweck
14e41ab119 Fix lamarzocco update status (#156442) 2025-11-12 13:10:23 +02:00
Timothy
46151456d8 Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-12 12:03:05 +01:00
cdnninja
39773a022a Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 11:59:45 +01:00
Christopher Fenner
5f49a6450f Add air quality sensors in ViCare integration (#156417) 2025-11-12 11:45:04 +01:00
Christopher Fenner
dc8425c580 Add icon for pm4 sensor (#156432) 2025-11-12 11:38:33 +01:00
Josef Zweck
910bd371e4 Remove wsproto from exceptions (#156434) 2025-11-12 11:16:36 +01:00
Tom Matheussen
802a225e11 Clean alarm control panel platform for Satel Integra (#156357) 2025-11-12 11:09:48 +01:00
Josef Zweck
84f66fa689 Fix aussie-broadband tests (#156441) 2025-11-12 10:54:23 +01:00
wollew
0b7e88d0e0 add parallel_updates for button entity (#156437) 2025-11-12 11:49:32 +02:00
puddly
1fcaf95df5 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-12 10:44:33 +01:00
Erik Montnemery
6c7434531f Fix tado tests opening sockets (#156386) 2025-11-12 10:08:15 +01:00
Åke Strandberg
5ec1c2b68b Use runtime_data in Senz (#156408) 2025-11-12 10:06:45 +01:00
Christopher Fenner
d8636d8346 Bump PyViCare to 2.55.0 (#156426) 2025-11-12 09:57:49 +01:00
Brett Adams
434763c74d Fix update progress in Teslemetry (#156422) 2025-11-12 09:55:09 +01:00
Petar Petrov
8cd2c1b43b Add power configuration to Energy dashboard (#153809)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 09:21:33 +01:00
Daniel Hjelseth Høyer
44711787a4 Update pyMill to 0.14.1 (#156396) 2025-11-12 09:15:59 +01:00
TheJulianJES
98fd0ee683 Exempt wsproto from license check (#156418) 2025-11-12 08:45:11 +01:00
Joost Lekkerkerker
303e4ce961 Add mac address to Velux device (#156376) 2025-11-12 09:45:02 +02:00
Paul Bottein
76f29298cd Add home panel (#156269) 2025-11-12 09:09:39 +02:00
Will Moss
17f5d0a69f Use common string for the remaining oauth2 error messages (#156407) 2025-11-12 04:43:12 +01:00
johanzander
90561de438 Refactor Growatt Server integration tests (#156413)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 00:32:25 +01:00
Marcello
becc106678 Merge branch 'dev' into flussButtonApi 2025-10-17 13:22:27 +02:00
Marcello
ca211b9504 more coverage 2025-10-17 13:21:58 +02:00
Marcello
110379402f using the conftest for testing 2025-10-17 12:11:26 +02:00
Marcello
2a9c9cf783 using await hass.config_entries.async_unload(mock_config_entry.entry_id) 2025-10-17 11:27:54 +02:00
Marcello
f9195d2212 using the conftest. fixture 2025-10-17 09:05:14 +02:00
Marcello
185eb91116 Update tests/components/fluss/test_init.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-17 07:37:47 +02:00
Marcello
6f4073a2b0 Merge branch 'dev' into flussButtonApi 2025-10-17 07:34:08 +02:00
Marcello
3542619e3a confirmation that it's being used 2025-10-14 14:54:53 +02:00
Marcello
b84285107b making button testable again 2025-10-14 14:52:16 +02:00
Marcello
526e225c8c Merge branch 'dev' into flussButtonApi 2025-10-14 11:22:26 +02:00
Marcello
7a9bcb52f9 test update for button 2025-10-14 11:21:59 +02:00
Marcello
6bdc60ae78 Merge branch 'flussButtonApi' of https://github.com/fluss/home-assistant into flussButtonApi 2025-10-14 11:05:37 +02:00
Marcello
24948c4794 updating the coordintator 2025-10-14 11:05:32 +02:00
Marcello
81cac62b26 Merge branch 'dev' into flussButtonApi 2025-10-14 10:35:54 +02:00
Marcello
67f0ab5ab2 fixing up the test 2025-10-13 13:47:10 +02:00
Marcello
afc2192723 updating the conftest file for multiple devices 2025-10-13 11:02:36 +02:00
Marcello
ef7aef84a3 using async configure 2025-10-13 10:48:02 +02:00
Marcello
8989cf037d snap shot platform 2025-10-13 10:35:01 +02:00
Marcello
b993b7c375 telling HA to. set up integration in the. test 2025-10-13 10:14:03 +02:00
Marcello
615ab3e165 Merge branch 'flussButtonApi' of https://github.com/fluss/home-assistant into flussButtonApi 2025-10-13 09:38:49 +02:00
Marcello
0eea2e66d4 direc mock and moving the test mock into the confest 2025-10-13 09:38:46 +02:00
Marcello
917a6f0adf Merge branch 'dev' into flussButtonApi 2025-10-13 09:09:52 +02:00
Marcello
3d6386ef8d Update tests/components/fluss/test_config_flow.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 14:30:30 +02:00
Marcello
02c9c43697 Update tests/components/fluss/test_config_flow.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 14:30:05 +02:00
Marcello
2423b56d93 the unique id is the api key 2025-10-10 14:26:56 +02:00
Marcello
dd5a3d2dad importing fluss domain 2025-10-10 14:22:57 +02:00
Marcello
b32bdd35b2 parsing api key in _async_abort_entries_match 2025-10-10 14:20:09 +02:00
Marcello
6cc636a547 Update tests/components/fluss/conftest.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 13:57:01 +02:00
Marcello
954a803087 Update homeassistant/components/fluss/strings.json
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 13:37:08 +02:00
Marcello
c57a46f777 Merge branch 'dev' into flussButtonApi 2025-10-10 13:09:38 +02:00
Marcello
286a3a0b53 Merge branch 'dev' into flussButtonApi 2025-10-10 13:04:51 +02:00
Marcello
f22e016efe Merge branch 'dev' into flussButtonApi 2025-10-10 12:24:29 +02:00
Marcello
430f7045ff changing it. todo 2025-10-10 12:23:48 +02:00
Marcello
b7c795e01f unique-config-entry 2025-10-10 12:10:44 +02:00
Marcello
10032ec3fc bettter comment forentity-disabled-by-default 2025-10-10 11:13:34 +02:00
Marcello
524a9f6851 button is an entity 2025-10-10 11:11:35 +02:00
Marcello
5ef84db2de type setting and using session 2025-10-10 10:07:25 +02:00
Marcello
2d1d8c1832 Merge branch 'dev' into flussButtonApi 2025-10-10 08:20:16 +02:00
Marcello
1c2b60e00b updating quality check 2025-10-10 08:20:01 +02:00
Marcello
c2fa3c7e3a Update homeassistant/components/fluss/quality_scale.yaml
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 08:17:57 +02:00
Marcello
adfcdd1425 Update homeassistant/components/fluss/quality_scale.yaml
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 08:17:40 +02:00
Marcello
c843221e09 Update homeassistant/components/fluss/coordinator.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 08:17:17 +02:00
Marcello
d2ff0c236d Update homeassistant/components/fluss/__init__.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 08:16:45 +02:00
Marcello
112d4dc745 Update homeassistant/components/fluss/coordinator.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 08:16:22 +02:00
Marcello
d5d4d667b7 Merge branch 'dev' into flussButtonApi 2025-10-09 08:52:02 +02:00
Marcello
4bb8358bc2 updating quality scale 2025-10-09 08:51:03 +02:00
Marcello
02539023db half way i guesss 2025-10-08 08:34:11 +02:00
Marcello
f675e73bfd Merge branch 'flussButtonApi' of https://github.com/fluss/home-assistant into flussButtonApi 2025-10-08 08:14:13 +02:00
Marcello
9862df3fea fixing test errors 2025-10-08 08:13:58 +02:00
Marcello
f323c4fe18 Merge branch 'dev' into flussButtonApi 2025-10-08 08:04:32 +02:00
Marcello
b83b3ad123 rename to test_init.py 2025-10-07 08:33:03 +02:00
Marcello
e1504e600a don't. need to test entity 2025-10-07 08:31:32 +02:00
Marcello
ea3e46d7bf no need to test the coordinittaor 2025-10-07 08:30:13 +02:00
Marcello
642ed83e27 don't. test the coorddintatoor 2025-10-07 08:27:54 +02:00
Marcello
81b4679dfa ddon't need to mock hass 2025-10-07 08:27:08 +02:00
Marcello
6abca41273 no fixture 2025-10-07 08:21:51 +02:00
Marcello
52ce506b5c merging into 1 2025-10-07 08:19:04 +02:00
Marcello
ed4094bf97 no need to test. this 2025-10-07 08:15:03 +02:00
Marcello
6e0a4b5506 moving mock config entrry i think 2025-10-07 08:14:03 +02:00
Marcello
f004aca704 no direct. integration for the test 2025-10-06 09:21:18 +02:00
Marcello
05e458a056 using the mock_config_entry to setup the integration via hass.config_entries.async_setup(entry.entry_id) 2025-10-06 09:06:16 +02:00
Marcello
9736429950 Update tests/components/fluss/__init__.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-06 08:50:02 +02:00
Marcello
ca44bc0bc3 recovery error. check 2025-10-06 08:47:31 +02:00
Marcello
a6a1b0b85f Merge branch 'flussButtonApi' of https://github.com/fluss/home-assistant into flussButtonApi 2025-10-06 08:46:48 +02:00
Marcello
28856d2d84 no need for ttry block and device dict 2025-10-06 08:46:44 +02:00
Marcello
b45be9ae59 Merge branch 'dev' into flussButtonApi 2025-10-06 08:32:51 +02:00
Marcello
9bd09017bb Merge branch 'dev' into flussButtonApi 2025-08-25 12:38:31 +02:00
Marcello
117574d109 hopefully this is platnum standards 2025-08-25 12:38:19 +02:00
Marcello
3c2419a02a adjusting the test files 2025-08-25 09:23:58 +02:00
Marcello
c1f25d59a2 Addjustiing from the suggestedcomments 2025-08-25 08:46:35 +02:00
Marcello
7abcb9c8b8 Update homeassistant/components/fluss/coordinator.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-08-25 08:03:32 +02:00
Marcello
35663175db Update homeassistant/components/fluss/button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-08-25 08:01:35 +02:00
Marcello
a28b447bb2 Merge branch 'dev' into flussButtonApi 2025-07-25 09:50:50 +02:00
Marcello
9f5132f685 move common fixtures to conftest.py 2025-07-25 09:50:29 +02:00
Marcello
ebdc70997f removing test data schema validation
HA takes care of that
2025-07-25 09:45:31 +02:00
Marcello
16a8bec8d1 paramitising errors 2025-07-25 09:44:18 +02:00
Marcello
0f38a7f811 updating configflow test 2025-07-25 09:39:24 +02:00
Marcello
aa8e13f48b Update tests/components/fluss/test_config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-25 09:31:16 +02:00
Marcello
243e96fded using async call 2025-07-21 10:37:23 +02:00
Marcello
64fdec14f6 get the button from the entity registry instead 2025-07-21 10:27:12 +02:00
Marcello
6606754521 not mock internals 2025-07-21 10:23:10 +02:00
Marcello
1f571c7fce let the integration set up itself and patch accordingly 2025-07-21 10:10:48 +02:00
Marcello
914fe97e22 adding. device info type 2025-07-21 10:03:15 +02:00
Marcello
a71a17191c using a fixture instead 2025-07-21 10:02:03 +02:00
Marcello
486686ae75 Update tests/components/fluss/test_button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:53:52 +02:00
Marcello
ca2747dfc5 using conf api key instead for test button 2025-07-21 09:51:39 +02:00
Marcello
e847bd44e7 no longer. need validate device 2025-07-21 09:45:59 +02:00
Marcello
c6a96d8d42 adding return types 2025-07-21 09:42:22 +02:00
Marcello
df3f80f24c Merge branch 'flussButtonApi' of https://github.com/fluss/home-assistant into flussButtonApi 2025-07-21 09:30:47 +02:00
Marcello
4489f90e46 exempt 2025-07-21 09:30:26 +02:00
Marcello
8791323092 Update homeassistant/components/fluss/quality_scale.yaml
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:28:06 +02:00
Marcello
1ffe7492cf Update homeassistant/components/fluss/quality_scale.yaml
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:27:49 +02:00
Marcello
21b941aef2 Update homeassistant/components/fluss/config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:27:29 +02:00
Marcello
178c280991 Update homeassistant/components/fluss/config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:24:04 +02:00
Marcello
3560ce5935 Update homeassistant/components/fluss/config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:08:01 +02:00
Marcello
f1512f9577 Update homeassistant/components/fluss/quality_scale.yaml
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 09:06:31 +02:00
Marcello
ce5a89b9b9 Update homeassistant/components/fluss/manifest.json
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 08:57:58 +02:00
Marcello
45e85e9cd2 using device name in the device info attr 2025-07-21 08:56:49 +02:00
Marcello
04c312c4c0 Update homeassistant/components/fluss/entity.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 08:05:07 +02:00
Marcello
aa2844a89a Update homeassistant/components/fluss/button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 07:55:41 +02:00
Marcello
0de64f6892 Update homeassistant/components/fluss/button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-07-21 07:51:21 +02:00
Marcello
32a9de7968 Merge branch 'dev' into flussButtonApi 2025-06-18 08:04:15 +02:00
Marcello
3f7628cd90 adding. the june 17 requested changes 2025-06-18 08:03:40 +02:00
Marcello
3a15527e12 removing description, better paramter call 2025-06-17 13:04:03 +02:00
Marcello
2028138371 Update homeassistant/components/fluss/__init__.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-17 10:29:41 +02:00
Marcello
a33b1792ff Merge branch 'dev' into flussButtonApi 2025-06-17 09:58:16 +02:00
Marcello
43db503ead finishing up the rest the rest of. the. finish suggestions 2025-06-17 09:53:50 +02:00
Marcello
484ee3eeea fixing some changes 2025-06-13 15:38:58 +02:00
Marcello
4b44064946 Merge branch 'dev' into flussButtonApi 2025-05-16 14:56:01 +02:00
Marcello
745228ff89 Applying changes the requested 2025-05-16 14:50:09 +02:00
Marcello
1a069b52f1 Merge branch 'dev' into flussButtonApi 2025-05-16 10:38:46 +02:00
Marcello
c95e5b8883 Revert "See if this works?"
This reverts commit 7309ed04a3.
2025-05-15 11:31:03 +02:00
Marcello
7309ed04a3 See if this works? 2025-05-15 11:26:58 +02:00
Marcello
bb62e9ec62 Merge branch 'dev' into flussButtonApi 2025-05-14 06:52:25 +02:00
Marcello
b2939a7bab Merge branch 'dev' into flussButtonApi 2025-04-30 10:42:25 +02:00
Marcello
5946ee1da6 updating the the quality scale. 2025-04-30 10:09:38 +02:00
Marcello
02f207ea7d Revert "updating test for config flow"
This reverts commit 198f71d69d.
2025-04-30 08:34:52 +02:00
Marcello
1877ecc845 updating the quality scale 2025-04-29 15:09:50 +02:00
Marcello
c7ebde041f stop point 2025-04-01 13:33:15 +02:00
Marcello
ef86318d3e full coverage of the test 2025-04-01 13:21:58 +02:00
Marcello
198f71d69d updating test for config flow 2025-04-01 13:18:41 +02:00
Marcello
a40579c813 Applying the requested fixes 2025-03-25 15:12:17 +02:00
Marcello
a67244e955 Update homeassistant/components/fluss/const.py
removing it since it's no longer needed

Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-03-25 15:06:39 +02:00
Marcello
6404e45dea Update homeassistant/components/fluss/button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-03-25 15:06:00 +02:00
Marcello
db0d1878cf Update homeassistant/components/fluss/__init__.py
raise this until we implement re-auth (in follow up)

Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-03-24 14:58:56 +02:00
Marcello
01db715d56 Update homeassistant/components/fluss/__init__.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-03-24 14:57:56 +02:00
Marcello
dd7ce1eaa9 adding this for now 2025-03-24 14:48:00 +02:00
Marcello
0aef6abbe7 Merge branch 'dev' into flussButtonApi 2025-03-24 14:07:52 +02:00
Marcello
2809503dfa Merge branch 'dev' into flussButtonApi 2025-03-18 10:55:25 +02:00
Marcello
bec73b9e0a Merge branch 'dev' into flussButtonApi 2025-03-17 13:41:57 +02:00
Marcello
4efc7e9160 Fix lint errors: add explicit return type annotations in test functions 2025-03-12 07:06:34 +00:00
Marcello
f3bc9e2feb Merge branch 'dev' into flussButtonApi 2025-03-12 07:50:03 +02:00
Marcello
b79b0da224 Merge branch 'dev' into flussButtonApi 2025-03-11 12:25:35 +02:00
Marcello
51b9285886 updating the init test file 2025-03-11 12:23:09 +02:00
Marcello
0bff111d81 Merge branch 'dev' into flussButtonApi 2025-03-11 10:19:57 +02:00
Marcello
b3ee022669 Merge branch 'dev' into flussButtonApi 2025-03-07 07:30:32 +02:00
Marcello
62c3bcc3ca Merge branch 'dev' into flussButtonApi 2025-03-06 12:20:44 +02:00
Marcello
8d46dbc9d2 Merge branch 'dev' into flussButtonApi 2025-03-06 12:11:25 +02:00
Marcello
b24d0e2998 Merge branch 'dev' into flussButtonApi 2025-03-06 12:07:27 +02:00
Marcello
467bb1bade Merge branch 'dev' into flussButton 2025-03-06 10:31:56 +02:00
Marcello
620cc3b6e3 Merge branch 'dev' into flussButton 2025-03-05 07:21:44 +02:00
Marcello
e0363d277f Merge branch 'dev' into flussButton 2025-03-03 12:45:13 +02:00
Marcello
c0cc359672 Removing hard coded api url and using a new library version 2025-03-03 12:44:01 +02:00
Marcello
4c36dd6f5b Merge branch 'dev' into flussButton 2025-02-18 07:53:27 +02:00
Marcello
de7d2c9714 Merge branch 'dev' into flussButton 2025-02-17 11:44:08 +02:00
Marcello
bb42dfa8c6 Revert "version bump"
This reverts commit 6532d6bfc6.
2025-02-17 09:09:43 +02:00
Marcello
6532d6bfc6 version bump 2025-02-17 08:52:52 +02:00
Marcello
b63e36f4bb Merge branch 'dev' into flussButton 2025-02-17 08:09:36 +02:00
Marcello
efbba90cad Merge branch 'dev' into flussButton 2025-02-13 15:10:48 +02:00
Marcello
b7a0b61933 Merge branch 'dev' into flussButton 2025-02-13 07:24:16 +02:00
Marcello
a681070b77 Merge branch 'dev' into flussButton 2025-02-12 10:51:39 +02:00
Marcello
2497713507 Merge branch 'dev' into flussButton 2025-02-10 07:16:49 +02:00
Marcello
990ef3b0ef Merge branch 'dev' into flussButton 2025-02-07 15:04:32 +02:00
Marcello
08cd9c1ba6 adjusting to the request 2025-02-07 12:23:11 +02:00
Marcello
4632ea34a7 Update homeassistant/components/fluss/button.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-07 12:09:30 +02:00
Marcello
404662f4af Update homeassistant/components/fluss/config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-07 11:43:09 +02:00
Marcello
b865c48bab Update homeassistant/components/fluss/config_flow.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-07 11:42:55 +02:00
Marcello
044fd08046 Update homeassistant/components/fluss/__init__.py
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-07 11:42:44 +02:00
NjDaGreat
1270ebc1a4 Version Bump 2024-10-31 13:04:50 +00:00
NjDaGreat
4aa41eaeac Extra Changes 2024-10-31 13:02:24 +00:00
NjDaGreat
04d9273021 Some Test Changes to ensure coverage 2024-10-31 11:48:17 +00:00
NjDaGreat
ae2c893d7b Added a Validation check for deviceId and refactored async setup entry 2024-10-31 10:42:16 +00:00
NjDaGreat
36b2949f32 Suggested Changes 2024-10-09 14:22:36 +00:00
NjDaGreat
330ed228be added the flussButton class to button.py and deleted device.py 2024-10-07 09:49:38 +00:00
NjDaGreat
81ca4bc181 Made some changes to the test for better interactions with API 2024-09-27 11:29:08 +00:00
NjDaGreat
20c539150d Made some changes to test file and api library required 2024-09-26 15:05:41 +00:00
NjDaGreat
60c76377bf Some General House Keeping to ensure adherance 2024-09-25 11:22:26 +00:00
NjDaGreat
3e0c40a047 Separated API into its own library 2024-09-25 09:42:59 +00:00
NjDaGreat
7c65c0df83 Fixed a couple issues as well as added a test file 2024-07-17 22:49:41 +00:00
NjeruFluss
976ebac457 Merge branch 'home-assistant:dev' into flussButton 2024-07-17 16:44:05 +02:00
NjeruFluss
7376835c7c Merge branch 'dev' into flussButton 2024-07-17 08:11:26 +02:00
NjeruFluss
4fc6b440e8 Merge branch 'dev' into flussButton 2024-07-16 10:04:00 +02:00
NjeruFluss
a1aaac5578 Merge branch 'home-assistant:dev' into flussButton 2024-07-16 09:03:10 +02:00
NjeruFluss
98fc5c22d1 Merge branch 'dev' into flussButton 2024-07-10 15:30:30 +02:00
NjeruFluss
ff1d4aaa76 Merge branch 'dev' into flussButton 2024-07-10 11:48:05 +02:00
NjeruFluss
34481d9b36 Merge branch 'dev' into flussButton 2024-07-09 16:40:45 +02:00
NjeruFluss
5d708e04d5 Update entity.py
Added some contextual names
2024-07-09 13:49:09 +02:00
NjDaGreat
7d016e8689 Removed commented code 2024-07-09 11:36:57 +00:00
NjDaGreat
43153dd61f Removed unnecessary code 2024-07-09 11:23:47 +00:00
NjDaGreat
56c4959639 APIstorage 2024-06-28 14:14:43 +00:00
NjDaGreat
196610fe33 button functionality added for fluss device 2024-06-28 13:40:34 +00:00
223 changed files with 21012 additions and 14084 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 1
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12"

2
CODEOWNERS generated
View File

@@ -516,6 +516,8 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck

View File

@@ -143,28 +143,5 @@
"name": "Trigger"
}
},
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "Triggers when an alarm is armed",
"fields": {
"mode": {
"description": "The arm modes to trigger on. If empty, triggers on all arm modes.",
"name": "Arm modes"
}
},
"name": "When an alarm is armed"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "Triggers when an alarm is disarmed",
"name": "When an alarm is disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "Triggers when an alarm is triggered",
"name": "When an alarm is triggered"
}
}
"title": "Alarm control panel"
}

View File

@@ -1,283 +0,0 @@
"""Provides triggers for alarm control panels."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, AlarmControlPanelState
CONF_MODE = "mode"
ARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_MODE, default=[]): vol.All(
cv.ensure_list,
[
vol.In(
[
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
]
)
],
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
DISARMED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TRIGGERED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class AlarmArmedTrigger(Trigger):
"""Trigger for when an alarm control panel is armed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, ARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm armed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
mode_filter = self._options[CONF_MODE]
# All armed states
armed_states = {
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
}
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is an armed state
if to_state.state not in armed_states:
return
# If mode filter is specified, check if the mode matches
if mode_filter and to_state.state not in mode_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm armed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmDisarmedTrigger(Trigger):
"""Trigger for when an alarm control panel is disarmed."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, DISARMED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm disarmed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is disarmed
if to_state.state != AlarmControlPanelState.DISARMED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm disarmed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class AlarmTriggeredTrigger(Trigger):
"""Trigger for when an alarm control panel is triggered."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TRIGGERED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the alarm triggered trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state is triggered
if to_state.state != AlarmControlPanelState.TRIGGERED:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"alarm triggered on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"armed": AlarmArmedTrigger,
"disarmed": AlarmDisarmedTrigger,
"triggered": AlarmTriggeredTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS

View File

@@ -1,30 +0,0 @@
armed:
target:
entity:
domain: alarm_control_panel
fields:
mode:
required: false
default: []
selector:
select:
multiple: true
options:
- value: armed_home
label: Home
- value: armed_away
label: Away
- value: armed_night
label: Night
- value: armed_vacation
label: Vacation
- value: armed_custom_bypass
label: Custom bypass
disarmed:
target:
entity:
domain: alarm_control_panel
triggered:
target:
entity:
domain: alarm_control_panel

View File

@@ -98,27 +98,5 @@
"name": "Start conversation"
}
},
"title": "Assist satellite",
"triggers": {
"listening": {
"description": "Triggers when a satellite starts listening for a command.",
"description_configured": "Triggers when a satellite starts listening for a command",
"name": "When a satellite starts listening"
},
"processing": {
"description": "Triggers when a satellite starts processing a command.",
"description_configured": "Triggers when a satellite starts processing a command",
"name": "When a satellite starts processing"
},
"responding": {
"description": "Triggers when a satellite starts responding to a command.",
"description_configured": "Triggers when a satellite starts responding to a command",
"name": "When a satellite starts responding"
},
"idle": {
"description": "Triggers when a satellite goes back to idle.",
"description_configured": "Triggers when a satellite goes back to idle",
"name": "When a satellite goes back to idle"
}
}
"title": "Assist satellite"
}

View File

@@ -1,140 +0,0 @@
"""Provides triggers for assist satellites."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import process_state_match
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
STATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class StateTriggerBase(Trigger):
"""Trigger for assist satellite state changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig, state: str) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._state = state
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
match_config_state = process_state_match(self._state)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if the new state matches the trigger state
if not match_config_state(to_state.state):
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"{entity_id} {self._state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ListeningTrigger(StateTriggerBase):
"""Trigger for when a satellite starts listening."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the listening trigger."""
super().__init__(hass, config, "listening")
class ProcessingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts processing."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the processing trigger."""
super().__init__(hass, config, "processing")
class RespondingTrigger(StateTriggerBase):
"""Trigger for when a satellite starts responding."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the responding trigger."""
super().__init__(hass, config, "responding")
class IdleTrigger(StateTriggerBase):
"""Trigger for when a satellite goes back to idle."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the idle trigger."""
super().__init__(hass, config, "idle")
TRIGGERS: dict[str, type[Trigger]] = {
"listening": ListeningTrigger,
"processing": ProcessingTrigger,
"responding": RespondingTrigger,
"idle": IdleTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for assist satellites."""
return TRIGGERS

View File

@@ -1,19 +0,0 @@
listening:
target:
entity:
domain: assist_satellite
processing:
target:
entity:
domain: assist_satellite
responding:
target:
entity:
domain: assist_satellite
idle:
target:
entity:
domain: assist_satellite

View File

@@ -285,93 +285,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate",
"triggers": {
"cooling": {
"description": "Triggers when a climate starts cooling.",
"name": "When a climate starts cooling"
},
"current_humidity_changed": {
"description": "Triggers when the current humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current humidity goes below this value.",
"name": "Below"
}
},
"name": "When current humidity changes"
},
"current_temperature_changed": {
"description": "Triggers when the current temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the current temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the current temperature goes below this value.",
"name": "Below"
}
},
"name": "When current temperature changes"
},
"drying": {
"description": "Triggers when a climate starts drying.",
"name": "When a climate starts drying"
},
"heating": {
"description": "Triggers when a climate starts heating.",
"name": "When a climate starts heating"
},
"mode_changed": {
"description": "Triggers when the HVAC mode of a climate changes.",
"fields": {
"hvac_mode": {
"description": "The HVAC modes to trigger on. If empty, triggers on all mode changes.",
"name": "HVAC modes"
}
},
"name": "When HVAC mode changes"
},
"target_humidity_changed": {
"description": "Triggers when the target humidity of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target humidity goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target humidity goes below this value.",
"name": "Below"
}
},
"name": "When target humidity changes"
},
"target_temperature_changed": {
"description": "Triggers when the target temperature of a climate changes.",
"fields": {
"above": {
"description": "Only trigger when the target temperature goes above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when the target temperature goes below this value.",
"name": "Below"
}
},
"name": "When target temperature changes"
},
"turns_off": {
"description": "Triggers when a climate turns off.",
"name": "When a climate turns off"
},
"turns_on": {
"description": "Triggers when a climate turns on.",
"name": "When a climate turns on"
}
}
"title": "Climate"
}

View File

@@ -1,817 +0,0 @@
"""Provides triggers for climate."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
CONF_ABOVE,
CONF_BELOW,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
DOMAIN,
HVAC_MODES,
HVACMode,
)
CLIMATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MODE_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_HVAC_MODE, default=[]): vol.All(
cv.ensure_list, [vol.In(HVAC_MODES)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
THRESHOLD_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_BELOW): vol.Coerce(float),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class ClimateTurnsOnTrigger(Trigger):
"""Trigger for when a climate turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned on (from off to any other mode)
if (
from_state is not None
and from_state.state == HVACMode.OFF
and to_state.state != HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTurnsOffTrigger(Trigger):
"""Trigger for when a climate turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate turned off (from any mode to off)
if (
from_state is not None
and from_state.state != HVACMode.OFF
and to_state.state == HVACMode.OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateModeChangedTrigger(Trigger):
"""Trigger for when a climate mode changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MODE_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate mode changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
hvac_modes_filter = self._options[ATTR_HVAC_MODE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if hvac_mode changed
if from_state is not None and from_state.state != to_state.state:
# If hvac_modes filter is specified, check if the new mode matches
if hvac_modes_filter and to_state.state not in hvac_modes_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} mode changed to {to_state.state}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCoolingTrigger(Trigger):
"""Trigger for when a climate starts cooling."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate cooling trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started cooling
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "cooling" and to_action == "cooling":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started cooling",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateHeatingTrigger(Trigger):
"""Trigger for when a climate starts heating."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate heating trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started heating
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "heating" and to_action == "heating":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started heating",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateDryingTrigger(Trigger):
"""Trigger for when a climate starts drying."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLIMATE_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate drying trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if climate started drying
from_action = from_state.attributes.get(ATTR_HVAC_ACTION) if from_state else None
to_action = to_state.attributes.get(ATTR_HVAC_ACTION)
if from_action != "drying" and to_action == "drying":
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} started drying",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate target temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target temperature changed
from_temp = (
from_state.attributes.get(ATTR_TEMPERATURE) if from_state else None
)
to_temp = to_state.attributes.get(ATTR_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentTemperatureChangedTrigger(Trigger):
"""Trigger for when a climate current temperature changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current temperature changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current temperature changed
from_temp = (
from_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if from_state
else None
)
to_temp = to_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
if to_temp is None or from_temp == to_temp:
return
# Apply threshold filters if specified
if above is not None and to_temp <= above:
return
if below is not None and to_temp >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current temperature changed to {to_temp}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateTargetHumidityChangedTrigger(Trigger):
"""Trigger for when a climate target humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate target humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if target humidity changed
from_humidity = (
from_state.attributes.get(ATTR_HUMIDITY) if from_state else None
)
to_humidity = to_state.attributes.get(ATTR_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} target humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class ClimateCurrentHumidityChangedTrigger(Trigger):
"""Trigger for when a climate current humidity changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, THRESHOLD_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the climate current humidity changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above = self._options.get(CONF_ABOVE)
below = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Check if current humidity changed
from_humidity = (
from_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if from_state
else None
)
to_humidity = to_state.attributes.get(ATTR_CURRENT_HUMIDITY)
if to_humidity is None or from_humidity == to_humidity:
return
# Apply threshold filters if specified
if above is not None and to_humidity <= above:
return
if below is not None and to_humidity >= below:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"climate {entity_id} current humidity changed to {to_humidity}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": ClimateTurnsOnTrigger,
"turns_off": ClimateTurnsOffTrigger,
"mode_changed": ClimateModeChangedTrigger,
"cooling": ClimateCoolingTrigger,
"heating": ClimateHeatingTrigger,
"drying": ClimateDryingTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"current_temperature_changed": ClimateCurrentTemperatureChangedTrigger,
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"current_humidity_changed": ClimateCurrentHumidityChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for climate."""
return TRIGGERS

View File

@@ -1,128 +0,0 @@
turns_on:
target:
entity:
domain: climate
turns_off:
target:
entity:
domain: climate
mode_changed:
target:
entity:
domain: climate
fields:
hvac_mode:
required: false
default: []
selector:
select:
multiple: true
mode: dropdown
options:
- label: "Off"
value: "off"
- label: "Heat"
value: "heat"
- label: "Cool"
value: "cool"
- label: "Heat/Cool"
value: "heat_cool"
- label: "Auto"
value: "auto"
- label: "Dry"
value: "dry"
- label: "Fan only"
value: "fan_only"
cooling:
target:
entity:
domain: climate
heating:
target:
entity:
domain: climate
drying:
target:
entity:
domain: climate
target_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
current_temperature_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
step: 0.1
below:
required: false
selector:
number:
mode: box
step: 0.1
target_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100
current_humidity_changed:
target:
entity:
domain: climate
fields:
above:
required: false
selector:
number:
mode: box
min: 0
max: 100
below:
required: false
selector:
number:
mode: box
min: 0
max: 100

View File

@@ -136,75 +136,5 @@
"name": "Toggle tilt"
}
},
"title": "Cover",
"triggers": {
"opens": {
"description": "Triggers when a cover opens.",
"description_configured": "Triggers when a cover opens",
"fields": {
"fully_opened": {
"description": "Only trigger when the cover is fully opened (position at 100%).",
"name": "Fully opened"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover opens"
},
"closes": {
"description": "Triggers when a cover closes.",
"description_configured": "Triggers when a cover closes",
"fields": {
"fully_closed": {
"description": "Only trigger when the cover is fully closed (position at 0%).",
"name": "Fully closed"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover closes"
},
"stops": {
"description": "Triggers when a cover stops moving.",
"description_configured": "Triggers when a cover stops moving",
"fields": {
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When a cover stops moving"
},
"position_changed": {
"description": "Triggers when the position of a cover changes.",
"description_configured": "Triggers when the position of a cover changes",
"fields": {
"lower": {
"description": "The minimum position value to trigger on. Only triggers when position is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum position value to trigger on. Only triggers when position is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when position is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when position is below this value.",
"name": "Below"
},
"device_class": {
"description": "The device classes to trigger on. If empty, triggers on all device classes.",
"name": "Device classes"
}
},
"name": "When the position of a cover changes"
}
}
"title": "Cover"
}

View File

@@ -1,453 +0,0 @@
"""Provides triggers for covers."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_CLASS,
CONF_OPTIONS,
CONF_TARGET,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
CONF_FULLY_OPENED = "fully_opened"
CONF_FULLY_CLOSED = "fully_closed"
OPENS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_OPENED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
CLOSES_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_FULLY_CLOSED, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPS_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
POSITION_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_UPPER, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_ABOVE, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Exclusive(CONF_BELOW, "position_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100)
),
vol.Optional(CONF_DEVICE_CLASS, default=[]): vol.All(
cv.ensure_list, [vol.Coerce(CoverDeviceClass)]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class CoverOpensTrigger(Trigger):
"""Trigger for when a cover opens."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, OPENS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover opens trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_opened = self._options[CONF_FULLY_OPENED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover opens or is opening
if to_state.state in (CoverState.OPEN, CoverState.OPENING):
# If fully_opened is True, only trigger when position reaches 100
if fully_opened:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 100:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_opened, allow triggering when position changes to 100
if fully_opened:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover opened on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverClosesTrigger(Trigger):
"""Trigger for when a cover closes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, CLOSES_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover closes trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
fully_closed = self._options[CONF_FULLY_CLOSED]
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover closes or is closing
if to_state.state in (CoverState.CLOSED, CoverState.CLOSING):
# If fully_closed is True, only trigger when position reaches 0
if fully_closed:
current_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if current_position != 0:
return
# Only trigger on state change, not if already in that state
if from_state and from_state.state == to_state.state:
# For fully_closed, allow triggering when position changes to 0
if fully_closed:
from_position = from_state.attributes.get(ATTR_CURRENT_POSITION)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
if from_position == to_position:
return
else:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover closed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverStopsTrigger(Trigger):
"""Trigger for when a cover stops moving."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPS_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover stops trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
device_classes_filter = self._options[CONF_DEVICE_CLASS]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Trigger when cover stops (from opening/closing to open/closed)
if from_state and from_state.state in (
CoverState.OPENING,
CoverState.CLOSING,
):
if to_state.state in (CoverState.OPEN, CoverState.CLOSED):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"cover stopped on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class CoverPositionChangedTrigger(Trigger):
"""Trigger for when a cover's position changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, POSITION_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the cover position changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
device_classes_filter = self._options.get(CONF_DEVICE_CLASS, [])
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Filter by device class if specified
if device_classes_filter:
device_class = to_state.attributes.get(CONF_DEVICE_CLASS)
if device_class not in device_classes_filter:
return
# Get position values
from_position = (
from_state.attributes.get(ATTR_CURRENT_POSITION) if from_state else None
)
to_position = to_state.attributes.get(ATTR_CURRENT_POSITION)
# Only trigger if position value exists and has changed
if to_position is None or from_position == to_position:
return
# Apply threshold filters if configured
if lower_limit is not None and to_position < lower_limit:
return
if upper_limit is not None and to_position > upper_limit:
return
if above_limit is not None and to_position <= above_limit:
return
if below_limit is not None and to_position >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_position": from_position,
"to_position": to_position,
},
f"position changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"opens": CoverOpensTrigger,
"closes": CoverClosesTrigger,
"stops": CoverStopsTrigger,
"position_changed": CoverPositionChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -1,101 +0,0 @@
opens:
target:
entity:
domain: cover
fields:
fully_opened:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
closes:
target:
entity:
domain: cover
fields:
fully_closed:
required: false
default: false
selector:
boolean:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
stops:
target:
entity:
domain: cover
fields:
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind
position_changed:
target:
entity:
domain: cover
fields:
lower:
required: false
selector:
number:
min: 0
max: 100
mode: box
upper:
required: false
selector:
number:
min: 0
max: 100
mode: box
above:
required: false
selector:
number:
min: 0
max: 100
mode: box
below:
required: false
selector:
number:
min: 0
max: 100
mode: box
device_class:
required: false
default: []
selector:
select:
multiple: true
options:
- curtain
- shutter
- blind

View File

@@ -9,7 +9,7 @@
},
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.2.20"],
"requirements": ["python-ecobee-api==0.3.2"],
"single_config_entry": true,
"zeroconf": [
{

View File

@@ -0,0 +1,84 @@
rules:
# todo : add get_feed_list to the library
# todo : see if we can drop some extra attributes
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
test_reconfigure_api_error should use a mock config entry fixture
test_user_flow_failure should use a mock config entry fixture
move test_user_flow_* to the top of the file
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
No events are explicitly registered by the integration.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
test the entry state in test_failure
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples:
status: exempt
comment: |
This integration does not provide any automation
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Awaitable, Callable
from typing import Literal, TypedDict
from typing import Literal, NotRequired, TypedDict
import voluptuous as vol
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
class FlowFromGridSourceType(TypedDict):
"""Dictionary describing the 'from' stat for the grid source."""
# statistic_id of a an energy meter (kWh)
# statistic_id of an energy meter (kWh)
stat_energy_from: str
# statistic_id of costs ($) incurred from the energy meter
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
number_energy_price: float | None # Price for energy ($/kWh)
class GridPowerSourceType(TypedDict):
"""Dictionary holding the source of grid power consumption."""
# statistic_id of a power meter (kW)
# negative values indicate grid return
stat_rate: str
class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]
cost_adjustment_day: float
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]
stat_energy_from: str
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
stat_rate: NotRequired[str]
class GasSourceType(TypedDict):
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
# Instantaneous rate of flow: W, L/min or m³/h
stat_rate: NotRequired[str]
# An optional custom name for display in energy graphs
name: str | None
# An optional statistic_id identifying a device
# that includes this device's consumption in its total
included_in_stat: str | None
included_in_stat: NotRequired[str]
class EnergyPreferences(TypedDict):
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
}
)
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once."""
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
[FLOW_TO_GRID_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_energy_to"),
),
vol.Optional("power"): vol.All(
[GRID_POWER_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_rate"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
)
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_rate"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_rate"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}

View File

@@ -12,6 +12,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
}
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
)
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS,
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
),
}
if issue_type == POWER_UNIT_ERROR:
return {
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
}
if issue_type == GAS_UNIT_ERROR:
return {
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
@callback
def _async_validate_usage_stat(
def _async_validate_stat_common(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
check_negative: bool = False,
) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
if stat_id not in metadata:
issues.add_issue(hass, "statistics_not_defined", stat_id)
has_entity_source = valid_entity_id(stat_id)
if not has_entity_source:
return
return None
entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id)
return
return None
if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id)
return
return None
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return
return None
try:
current_value: float | None = float(state.state)
except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return
return None
if current_value is not None and current_value < 0:
if check_negative and current_value is not None and current_value < 0:
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit)
return entity_id
@callback
def _async_validate_usage_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=True,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
issues.add_issue(hass, unit_error, entity_id, unit)
@callback
def _async_validate_power_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a power statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=False,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
if state_class != sensor.SensorStateClass.MEASUREMENT:
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
@callback
def _async_validate_cost_stat(
hass: HomeAssistant,
@@ -309,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
def _validate_grid_source(
hass: HomeAssistant,
source: data.GridSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate grid energy source."""
flow_from: data.FlowFromGridSourceType
for flow_from in source["flow_from"]:
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_from["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow_from.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_from.get("entity_energy_price") is not None
or flow_from.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_from["stat_energy_from"],
source_result,
)
)
flow_to: data.FlowToGridSourceType
for flow_to in source["flow_to"]:
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_to["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_to.get("entity_energy_price") is not None
or flow_to.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_to["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
def _validate_gas_source(
hass: HomeAssistant,
source: data.GasSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate gas energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
source: data.WaterSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate water energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""
manager: data.EnergyManager = await data.async_get_manager(hass)
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
validate_calls = []
validate_calls: list[functools.partial[None]] = []
wanted_statistics_metadata: set[str] = set()
result = EnergyPreferencesValidation()
@@ -327,215 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
result.energy_sources.append(source_result)
if source["type"] == "grid":
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
for flow in source["flow_from"]:
wanted_statistics_metadata.add(flow["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_from"],
source_result,
)
)
for flow in source["flow_to"]:
wanted_statistics_metadata.add(flow["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_to"],
source_result,
)
)
_validate_grid_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
_validate_gas_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "water":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
_validate_water_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "solar":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(

View File

@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
"ctmeters": envoy_data.ctmeters,
"ctmeters_phases": envoy_data.ctmeters_phases,
"dry_contact_status": envoy_data.dry_contact_status,
"dry_contact_settings": envoy_data.dry_contact_settings,
"inverters": envoy_data.inverters,
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
"ct_consumption_meter": envoy.consumption_meter_type,
"ct_production_meter": envoy.production_meter_type,
"ct_storage_meter": envoy.storage_meter_type,
"ct_meters": list(envoy_data.ctmeters.keys()),
}
fixture_data: dict[str, Any] = {}

View File

@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
cttype: str | None = None
CT_NET_CONSUMPTION_SENSORS = (
EnvoyCTSensorEntityDescription(
key="lifetime_net_consumption",
translation_key="lifetime_net_consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="lifetime_net_production",
translation_key="lifetime_net_production",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption",
translation_key="net_consumption",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="frequency",
translation_key="net_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="voltage",
translation_key="net_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_current",
translation_key="net_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_powerfactor",
translation_key="net_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_metering_status",
translation_key="net_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_status_flags",
translation_key="net_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
)
CT_NET_CONSUMPTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
# All ct types unified in common setup
CT_SENSORS = (
[
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
# Production CT energy_delivered is not used
(CtType.STORAGE, "lifetime_battery_discharged"),
)
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
]
for phase in range(3)
}
CT_PRODUCTION_SENSORS = (
EnvoyCTSensorEntityDescription(
key="production_ct_frequency",
translation_key="production_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_voltage",
translation_key="production_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_current",
translation_key="production_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_powerfactor",
translation_key="production_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_metering_status",
translation_key="production_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.PRODUCTION,
),
EnvoyCTSensorEntityDescription(
key="production_ct_status_flags",
translation_key="production_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.PRODUCTION,
),
)
CT_PRODUCTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
# Production CT energy_received is not used
(CtType.STORAGE, "lifetime_battery_charged"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_consumption"),
# Production CT active_power is not used
(CtType.STORAGE, "battery_discharge"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
(CtType.PRODUCTION, "production_ct_frequency", ""),
(CtType.STORAGE, "storage_ct_frequency", ""),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
(CtType.PRODUCTION, "production_ct_voltage", ""),
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_current"),
(CtType.PRODUCTION, "production_ct_current"),
(CtType.STORAGE, "storage_ct_current"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
(CtType.PRODUCTION, "production_ct_powerfactor"),
(CtType.STORAGE, "storage_ct_powerfactor"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_metering_status",
"net_ct_metering_status",
),
(CtType.PRODUCTION, "production_ct_metering_status", ""),
(CtType.STORAGE, "storage_ct_metering_status", ""),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_status_flags",
"net_ct_status_flags",
),
(CtType.PRODUCTION, "production_ct_status_flags", ""),
(CtType.STORAGE, "storage_ct_status_flags", ""),
)
for sensor in list(CT_PRODUCTION_SENSORS)
]
for phase in range(3)
}
CT_STORAGE_SENSORS = (
EnvoyCTSensorEntityDescription(
key="lifetime_battery_discharged",
translation_key="lifetime_battery_discharged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="lifetime_battery_charged",
translation_key="lifetime_battery_charged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="battery_discharge",
translation_key="battery_discharge",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_frequency",
translation_key="storage_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_voltage",
translation_key="storage_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_current",
translation_key="storage_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_powerfactor",
translation_key="storage_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_metering_status",
translation_key="storage_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_status_flags",
translation_key="storage_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.STORAGE,
),
)
CT_STORAGE_PHASE_SENSORS = {
CT_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
)
for sensor in list(CT_STORAGE_SENSORS)
for sensor in list(CT_SENSORS)
]
for phase in range(3)
}
@@ -1060,24 +919,14 @@ async def async_setup_entry(
if envoy_data.ctmeters:
entities.extend(
EnvoyCTEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_SENSORS,
CT_PRODUCTION_SENSORS,
CT_STORAGE_SENSORS,
)
for description in sensors
for description in CT_SENSORS
if description.cttype in envoy_data.ctmeters
)
# Add Current Transformer phase entities
if ctmeters_phases := envoy_data.ctmeters_phases:
entities.extend(
EnvoyCTPhaseEntity(coordinator, description)
for sensors in (
CT_NET_CONSUMPTION_PHASE_SENSORS,
CT_PRODUCTION_PHASE_SENSORS,
CT_STORAGE_PHASE_SENSORS,
)
for phase, descriptions in sensors.items()
for phase, descriptions in CT_PHASE_SENSORS.items()
for description in descriptions
if (cttype := description.cttype) in ctmeters_phases
and phase in ctmeters_phases[cttype]

View File

@@ -0,0 +1,31 @@
"""The Fluss+ integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from .coordinator import FlussDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON]
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,
) -> bool:
"""Set up Fluss+ from a config entry."""
coordinator = FlussDataUpdateCoordinator(hass, entry, entry.data[CONF_API_KEY])
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FlussConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,38 @@
"""Support for Fluss Devices."""
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator
from .entity import FlussEntity
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fluss Devices, filtering out any invalid payloads."""
coordinator = entry.runtime_data
devices = coordinator.data
async_add_entities(
FlussButton(coordinator, device_id, device)
for device_id, device in devices.items()
)
class FlussButton(FlussEntity, ButtonEntity):
"""Representation of a Fluss button device."""
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.coordinator.api.async_trigger_device(self.device_id)
except FlussApiClientError as err:
raise HomeAssistantError(f"Failed to trigger device: {err}") from err

View File

@@ -0,0 +1,54 @@
"""Config flow for Fluss+ integration."""
from __future__ import annotations
from typing import Any
from fluss_api import (
FlussApiClient,
FlussApiClientAuthenticationError,
FlussApiClientCommunicationError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fluss+."""
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:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
try:
FlussApiClient(
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
)
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title="My Fluss+ Devices", data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Fluss+ integration."""
from datetime import timedelta
import logging
DOMAIN = "fluss"
LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = 60 # seconds
UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL)

View File

@@ -0,0 +1,50 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
from typing import Any
from fluss_api import (
FlussApiClient,
FlussApiClientAuthenticationError,
FlussApiClientError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages fetching Fluss device data on a schedule."""
def __init__(
self, hass: HomeAssistant, config_entry: FlussConfigEntry, api_key: str
) -> None:
"""Initialize the coordinator."""
self.api = FlussApiClient(api_key, session=async_get_clientsession(hass))
super().__init__(
hass,
LOGGER,
name=f"Fluss+ ({slugify(api_key[:8])})",
config_entry=config_entry,
update_interval=UPDATE_INTERVAL_TIMEDELTA,
)
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch data from the Fluss API and return as a dictionary keyed by deviceId."""
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
return {device["deviceId"]: device for device in devices.get("devices", [])}

View File

@@ -0,0 +1,36 @@
"""Base entities for the Fluss+ integration."""
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FlussDataUpdateCoordinator
class FlussEntity(CoordinatorEntity[FlussDataUpdateCoordinator]):
"""Base class for Fluss entities."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: FlussDataUpdateCoordinator,
device_id: str,
device: dict,
) -> None:
"""Initialize the entity with a device ID and device data."""
super().__init__(coordinator)
self.device_id = device_id
self._device = device
self._attr_unique_id = f"{device_id}"
self._attr_device_info = DeviceInfo(
identifiers={("fluss", device_id)},
name=device.get("deviceName"),
manufacturer="Fluss",
model="Fluss+ Device",
)
@property
def device(self) -> dict:
"""Return the stored device data."""
return self._device

View File

@@ -0,0 +1,11 @@
{
"domain": "fluss",
"name": "Fluss+",
"codeowners": ["@fluss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fluss",
"iot_class": "cloud_polling",
"loggers": ["fluss-api"],
"quality_scale": "bronze",
"requirements": ["fluss-api==0.1.9.17"]
}

View File

@@ -0,0 +1,69 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions present
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
entity-translations: done
entity-device-class: done
devices: done
entity-category: done
entity-disabled-by-default:
status: exempt
comment: |
Not needed
discovery: todo
stale-devices: todo
diagnostics: todo
exception-translations: todo
icon-translations:
status: exempt
comment: |
No icons used
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
repair-issues:
status: exempt
comment: |
No issues to repair
docs-use-cases: done
docs-supported-devices: todo
docs-supported-functions: done
docs-data-update: todo
docs-known-limitations: done
docs-troubleshooting: todo
docs-examples: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"description": "Your Fluss API key, available in the profile page of the Fluss+ app",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key found in the profile page of the Fluss+ app."
}
}
},
"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%]"
}
}
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from urllib.parse import quote
import voluptuous as vol
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
async def stream_source(self) -> str | None:
"""Return the stream source."""
if self._rtsp_port:
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
_username = quote(self._username)
_password = quote(self._password)
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
return None

View File

@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate",
sidebar_default_visible=False,
)
async_register_built_in_panel(
hass,
"home",
sidebar_icon="mdi:home",
sidebar_title="home",
sidebar_default_visible=False,
)
async_register_built_in_panel(hass, "profile")

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
}

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.0"],
"requirements": ["aiohomeconnect==0.23.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Set the program value."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
self._attr_current_option = (
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
if event
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
if event and isinstance(event_value := event.value, str)
else None
)

View File

@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
self._update_native_value(status)
def _update_native_value(self, status: str | float) -> None:
def _update_native_value(self, status: str | float | None) -> None:
"""Set the value of the sensor based on the given value."""
if status is None:
self._attr_native_value = None
return
match self.device_class:
case SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dt_util.utcnow() + timedelta(

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"quality_scale": "bronze",
"usb": [
{

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__)
@@ -134,7 +134,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR]
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.37",
"universal-silabs-flasher==0.1.0",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = []
BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
**(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
if probe_methods
else {}
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
)
if fw_info is None:
return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
f" not in application probe methods {application_probe_methods!r}"
)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
if probed_firmware_info is None:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""
placeholders = {

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"usb": [
{
"description": "*skyconnect v1.0*",

View File

@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import (
DOMAIN,
FIRMWARE,
@@ -151,8 +152,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (

View File

@@ -7,5 +7,11 @@
"dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"single_config_entry": true
}

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.2"]
"requirements": ["pylamarzocco==2.1.3"]
}

View File

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware()
while (
update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateStatus.IN_PROGRESS:
).command_status is not UpdateStatus.UPDATED:
if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -462,40 +462,5 @@
}
}
},
"title": "Light",
"triggers": {
"turns_on": {
"description": "Triggers when a light turns on.",
"description_configured": "Triggers when a light turns on",
"name": "When a light turns on"
},
"turns_off": {
"description": "Triggers when a light turns off.",
"description_configured": "Triggers when a light turns off",
"name": "When a light turns off"
},
"brightness_changed": {
"description": "Triggers when the brightness of a light changes.",
"description_configured": "Triggers when the brightness of a light changes",
"fields": {
"lower": {
"description": "The minimum brightness value to trigger on. Only triggers when brightness is at or above this value.",
"name": "Lower limit"
},
"upper": {
"description": "The maximum brightness value to trigger on. Only triggers when brightness is at or below this value.",
"name": "Upper limit"
},
"above": {
"description": "Only trigger when brightness is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when brightness is below this value.",
"name": "Below"
}
},
"name": "When the brightness of a light changes"
}
}
"title": "Light"
}

View File

@@ -1,288 +0,0 @@
"""Provides triggers for lights."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
ATTR_BRIGHTNESS = "brightness"
CONF_LOWER = "lower"
CONF_UPPER = "upper"
CONF_ABOVE = "above"
CONF_BELOW = "below"
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
BRIGHTNESS_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Exclusive(CONF_LOWER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_UPPER, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_ABOVE, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Exclusive(CONF_BELOW, "brightness_range"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class LightTurnsOnTrigger(Trigger):
"""Trigger for when a light turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns on (from off to on)
if from_state and from_state.state == STATE_OFF and to_state.state == STATE_ON:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned on on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightTurnsOffTrigger(Trigger):
"""Trigger for when a light turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when light turns off (from on to off)
if from_state and from_state.state == STATE_ON and to_state.state == STATE_OFF:
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"light turned off on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class LightBrightnessChangedTrigger(Trigger):
"""Trigger for when a light's brightness changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, BRIGHTNESS_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the light brightness changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
self._options = config.options or {}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
lower_limit = self._options.get(CONF_LOWER)
upper_limit = self._options.get(CONF_UPPER)
above_limit = self._options.get(CONF_ABOVE)
below_limit = self._options.get(CONF_BELOW)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get brightness values
from_brightness = (
from_state.attributes.get(ATTR_BRIGHTNESS) if from_state else None
)
to_brightness = to_state.attributes.get(ATTR_BRIGHTNESS)
# Only trigger if brightness value exists and has changed
if to_brightness is None or from_brightness == to_brightness:
return
# Apply threshold filters if configured
if lower_limit is not None and to_brightness < lower_limit:
return
if upper_limit is not None and to_brightness > upper_limit:
return
if above_limit is not None and to_brightness <= above_limit:
return
if below_limit is not None and to_brightness >= below_limit:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
"from_brightness": from_brightness,
"to_brightness": to_brightness,
},
f"brightness changed on {entity_id}",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": LightTurnsOnTrigger,
"turns_off": LightTurnsOffTrigger,
"brightness_changed": LightBrightnessChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lights."""
return TRIGGERS

View File

@@ -1,43 +0,0 @@
turns_on:
target:
entity:
domain: light
turns_off:
target:
entity:
domain: light
brightness_changed:
target:
entity:
domain: light
fields:
lower:
required: false
selector:
number:
min: 0
max: 255
mode: box
upper:
required: false
selector:
number:
min: 0
max: 255
mode: box
above:
required: false
selector:
number:
min: 0
max: 255
mode: box
below:
required: false
selector:
number:
min: 0
max: 255
mode: box

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.5.3"]
"requirements": ["lunatone-rest-api-client==0.5.7"]
}

View File

@@ -367,63 +367,5 @@
"name": "Turn up volume"
}
},
"title": "Media player",
"triggers": {
"turns_on": {
"description": "Triggers when a media player turns on.",
"description_configured": "Triggers when a media player turns on",
"name": "When a media player turns on"
},
"turns_off": {
"description": "Triggers when a media player turns off.",
"description_configured": "Triggers when a media player turns off",
"name": "When a media player turns off"
},
"playing": {
"description": "Triggers when a media player starts playing.",
"description_configured": "Triggers when a media player starts playing",
"fields": {
"media_content_type": {
"description": "The media content types to trigger on. If empty, triggers on all content types.",
"name": "Media content types"
}
},
"name": "When a media player starts playing"
},
"paused": {
"description": "Triggers when a media player pauses.",
"description_configured": "Triggers when a media player pauses",
"name": "When a media player pauses"
},
"stopped": {
"description": "Triggers when a media player stops playing.",
"description_configured": "Triggers when a media player stops playing",
"name": "When a media player stops playing"
},
"muted": {
"description": "Triggers when a media player gets muted.",
"description_configured": "Triggers when a media player gets muted",
"name": "When a media player gets muted"
},
"unmuted": {
"description": "Triggers when a media player gets unmuted.",
"description_configured": "Triggers when a media player gets unmuted",
"name": "When a media player gets unmuted"
},
"volume_changed": {
"description": "Triggers when a media player volume changes.",
"description_configured": "Triggers when a media player volume changes",
"fields": {
"above": {
"description": "Only trigger when volume is above this level (0.0-1.0).",
"name": "Above"
},
"below": {
"description": "Only trigger when volume is below this level (0.0-1.0).",
"name": "Below"
}
},
"name": "When a media player volume changes"
}
}
"title": "Media player"
}

View File

@@ -1,676 +0,0 @@
"""Provides triggers for media players."""
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN,
)
TURNS_ON_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
TURNS_OFF_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PLAYING_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional(ATTR_MEDIA_CONTENT_TYPE, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
STOPPED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
MUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
UNMUTED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
VOLUME_CHANGED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {
vol.Optional("above"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
vol.Optional("below"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
PAUSED_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_OPTIONS, default={}): {},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class MediaPlayerTurnsOnTrigger(Trigger):
"""Trigger for when a media player turns on."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_ON_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns on trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning on from off state
if (
from_state is not None
and from_state.state == STATE_OFF
and to_state.state != STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned on",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerTurnsOffTrigger(Trigger):
"""Trigger for when a media player turns off."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, TURNS_OFF_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player turns off trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when turning off
if (
from_state is not None
and from_state.state != STATE_OFF
and to_state.state == STATE_OFF
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} turned off",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPlayingTrigger(Trigger):
"""Trigger for when a media player starts playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PLAYING_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player playing trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
media_content_types_filter = self._options[ATTR_MEDIA_CONTENT_TYPE]
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when starting to play
if (
from_state is not None
and from_state.state != STATE_PLAYING
and to_state.state == STATE_PLAYING
):
# If media_content_type filter is specified, check if it matches
if media_content_types_filter:
media_content_type = to_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE)
if media_content_type not in media_content_types_filter:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} started playing",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerPausedTrigger(Trigger):
"""Trigger for when a media player pauses."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, PAUSED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player paused trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when pausing
if (
from_state is not None
and from_state.state != STATE_PAUSED
and to_state.state == STATE_PAUSED
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} paused",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerStoppedTrigger(Trigger):
"""Trigger for when a media player stops playing."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, STOPPED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player stopped trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when stopping (to idle or off from playing/paused states)
if (
from_state is not None
and from_state.state in (STATE_PLAYING, STATE_PAUSED)
and to_state.state in (STATE_IDLE, STATE_OFF)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} stopped",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerMutedTrigger(Trigger):
"""Trigger for when a media player gets muted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, MUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player muted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when muting
if (
from_state is not None
and not from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} muted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerUnmutedTrigger(Trigger):
"""Trigger for when a media player gets unmuted."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, UNMUTED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player unmuted trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Trigger when unmuting
if (
from_state is not None
and from_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
and not to_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED, False)
):
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} unmuted",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
class MediaPlayerVolumeChangedTrigger(Trigger):
"""Trigger for when a media player volume changes."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, VOLUME_CHANGED_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the media player volume changed trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
above_threshold = self._options.get("above")
below_threshold = self._options.get("below")
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# Ignore unavailable states
if to_state is None or to_state.state == STATE_UNAVAILABLE:
return
# Get volume levels
old_volume = (
from_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
if from_state is not None
else None
)
new_volume = to_state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL)
# Volume must have changed
if old_volume == new_volume or new_volume is None:
return
# Check thresholds if specified
if above_threshold is not None and new_volume <= above_threshold:
return
if below_threshold is not None and new_volume >= below_threshold:
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"media player {entity_id} volume changed",
event.context,
)
def entity_filter(entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, entity_filter
)
TRIGGERS: dict[str, type[Trigger]] = {
"turns_on": MediaPlayerTurnsOnTrigger,
"turns_off": MediaPlayerTurnsOffTrigger,
"playing": MediaPlayerPlayingTrigger,
"paused": MediaPlayerPausedTrigger,
"stopped": MediaPlayerStoppedTrigger,
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": MediaPlayerVolumeChangedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for media players."""
return TRIGGERS

View File

@@ -1,65 +0,0 @@
turns_on:
target:
entity:
domain: media_player
turns_off:
target:
entity:
domain: media_player
playing:
target:
entity:
domain: media_player
fields:
media_content_type:
required: false
default: []
selector:
select:
multiple: true
custom_value: true
options: []
paused:
target:
entity:
domain: media_player
stopped:
target:
entity:
domain: media_player
muted:
target:
entity:
domain: media_player
unmuted:
target:
entity:
domain: media_player
volume_changed:
target:
entity:
domain: media_player
fields:
above:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider
below:
required: false
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
}

View File

@@ -61,10 +61,12 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -72,10 +72,12 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -109,7 +109,7 @@
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_failed": {
"message": "Failed to update drive state"

View File

@@ -36,7 +36,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
from ..const import SupportedDialect
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
from ..util import session_scope
if TYPE_CHECKING:
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
or dialect_kwargs.get("mariadb_collate")
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
)
if collate and collate != "utf8mb4_unicode_ci":
if collate and collate != MYSQL_COLLATE:
_LOGGER.debug(
"Database %s collation is not utf8mb4_unicode_ci",
"Database %s collation is not %s",
table,
MYSQL_COLLATE,
)
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
return schema_errors
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
table_name = table_object.__tablename__
if (
f"{table_name}.4-byte UTF-8" in schema_errors
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
):
from ..migration import ( # noqa: PLC0415
_correct_table_character_set_and_collation,

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52
SCHEMA_VERSION = 53
_LOGGER = logging.getLogger(__name__)
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci"
MYSQL_COLLATE = "utf8mb4_bin"
MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB"

View File

@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of the statistic_meta table
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in ("events", "states", "statistics_meta"):
_correct_table_character_set_and_collation(table, self.session_maker)
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
)
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in (
"events",
"event_data",
"states",
"state_attributes",
"statistics",
"statistics_meta",
"statistics_short_term",
):
_correct_table_character_set_and_collation(table, self.session_maker)
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
"""Correct issues detected by validate_db_schema."""
# Attempt to convert the table to utf8mb4
_LOGGER.warning(
"Updating character set and collation of table %s to utf8mb4. %s",
"Updating table %s to character set %s and collation %s. %s",
table,
MYSQL_DEFAULT_CHARSET,
MYSQL_COLLATE,
MIGRATION_NOTE_MINUTES,
)
with (

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ruuvitag BLE device from a config entry."""
"""Set up Ruuvi BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = RuuvitagBluetoothDeviceData()

View File

@@ -1,6 +1,6 @@
{
"domain": "ruuvitag_ble",
"name": "RuuviTag BLE",
"name": "Ruuvi BLE",
"bluetooth": [
{
"connectable": false,

View File

@@ -191,7 +191,7 @@ async def async_setup_entry(
entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ruuvitag BLE sensors."""
"""Set up the Ruuvi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
],
SensorEntity,
):
"""Representation of a Ruuvitag BLE sensor."""
"""Representation of a Ruuvi BLE sensor."""
@property
def native_value(self) -> int | float | None:

View File

@@ -3,10 +3,9 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import logging
from satel_integra.satel_integra import AlarmState
from satel_integra.satel_integra import AlarmState, AsyncSatel
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -14,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -26,6 +25,19 @@ from .const import (
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
ALARM_STATE_MAP = {
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
}
_LOGGER = logging.getLogger(__name__)
@@ -45,48 +57,54 @@ async def async_setup_entry(
)
for subentry in partition_subentries:
partition_num = subentry.data[CONF_PARTITION_NUMBER]
zone_name = subentry.data[CONF_NAME]
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
async_add_entities(
[
SatelIntegraAlarmPanel(
controller,
zone_name,
arm_home_mode,
partition_num,
config_entry.entry_id,
subentry,
partition_num,
arm_home_mode,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
"""Representation of an AlarmDecoder-based alarm panel."""
_attr_code_format = CodeFormat.NUMBER
_attr_should_poll = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id
self,
controller: AsyncSatel,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
arm_home_mode: int,
) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
_LOGGER.debug("Starts listening for panel messages")
self._update_alarm_status()
self._attr_alarm_state = self._read_alarm_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
@@ -94,55 +112,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
)
@callback
def _update_alarm_status(self):
def _update_alarm_status(self) -> None:
"""Handle alarm status update."""
state = self._read_alarm_state()
_LOGGER.debug("Got status update, current status: %s", state)
if state != self._attr_alarm_state:
self._attr_alarm_state = state
self.async_write_ha_state()
else:
_LOGGER.debug("Ignoring alarm status message, same state")
def _read_alarm_state(self):
def _read_alarm_state(self) -> AlarmControlPanelState | None:
"""Read current status of the alarm and translate it into HA status."""
# Default - disarmed:
hass_alarm_status = AlarmControlPanelState.DISARMED
if not self._satel.connected:
_LOGGER.debug("Alarm panel not connected")
return None
state_map = OrderedDict(
[
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
(
AlarmState.EXIT_COUNTDOWN_OVER_10,
AlarmControlPanelState.PENDING,
),
(
AlarmState.EXIT_COUNTDOWN_UNDER_10,
AlarmControlPanelState.PENDING,
),
]
)
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
for satel_state, ha_state in state_map.items():
for satel_state, ha_state in ALARM_STATE_MAP.items():
if (
satel_state in self._satel.partition_states
and self._partition_id in self._satel.partition_states[satel_state]
and self._device_number in self._satel.partition_states[satel_state]
):
hass_alarm_status = ha_state
break
return ha_state
return hass_alarm_status
return AlarmControlPanelState.DISARMED
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -154,25 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
await self._satel.disarm(code, [self._partition_id])
await self._satel.disarm(code, [self._device_number])
if clear_alarm_necessary:
# Wait 1s before clearing the alarm
await asyncio.sleep(1)
await self._satel.clear_alarm(code, [self._partition_id])
await self._satel.clear_alarm(code, [self._device_number])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
_LOGGER.debug("Arming away")
if code:
await self._satel.arm(code, [self._partition_id])
await self._satel.arm(code, [self._device_number])
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
_LOGGER.debug("Arming home")
if code:
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
await self._satel.arm(code, [self._device_number], self._arm_home_mode)

View File

@@ -8,25 +8,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_ZONE,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -46,18 +43,16 @@ async def async_setup_entry(
for subentry in zone_subentries:
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
zone_num,
zone_name,
zone_type,
CONF_ZONES,
SIGNAL_ZONES_UPDATED,
config_entry.entry_id,
subentry,
zone_num,
zone_type,
SIGNAL_ZONES_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
@@ -71,51 +66,44 @@ async def async_setup_entry(
for subentry in output_subentries:
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
output_num,
output_name,
ouput_type,
CONF_OUTPUTS,
SIGNAL_OUTPUTS_UPDATED,
config_entry.entry_id,
subentry,
output_num,
ouput_type,
SIGNAL_OUTPUTS_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraBinarySensor(BinarySensorEntity):
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
"""Representation of an Satel Integra binary sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
device_class: BinarySensorDeviceClass,
sensor_type: str,
react_to_signal: str,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
device_class: BinarySensorDeviceClass,
react_to_signal: str,
) -> None:
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._react_to_signal = react_to_signal
self._satel = controller
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
self._react_to_signal = react_to_signal
async def async_added_to_hass(self) -> None:
"""Register callbacks."""

View File

@@ -0,0 +1,58 @@
"""Satel Integra base entity."""
from __future__ import annotations
from typing import TYPE_CHECKING
from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import (
DOMAIN,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
)
SubentryTypeToEntityType: dict[str, str] = {
SUBENTRY_TYPE_PARTITION: "alarm_panel",
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: "switch",
SUBENTRY_TYPE_ZONE: "zones",
SUBENTRY_TYPE_OUTPUT: "outputs",
}
class SatelIntegraEntity(Entity):
"""Defines a base Satel Integra entity."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
) -> None:
"""Initialize the Satel Integra entity."""
self._satel = controller
self._device_number = device_number
entity_type = SubentryTypeToEntityType[subentry.subentry_type]
if TYPE_CHECKING:
assert entity_type is not None
self._attr_unique_id = f"{config_entry_id}_{entity_type}_{device_number}"
self._attr_device_info = DeviceInfo(
name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}
)

View File

@@ -7,19 +7,19 @@ from typing import Any
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_CODE, CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SWITCHABLE_OUTPUT_NUMBER,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -38,47 +38,42 @@ async def async_setup_entry(
for subentry in switchable_output_subentries:
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
switchable_output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraSwitch(
controller,
switchable_output_num,
switchable_output_name,
config_entry.options.get(CONF_CODE),
config_entry.entry_id,
subentry,
switchable_output_num,
config_entry.options.get(CONF_CODE),
),
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraSwitch(SwitchEntity):
"""Representation of an Satel switch."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
"""Representation of an Satel Integra switch."""
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
code: str | None,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
code: str | None,
) -> None:
"""Initialize the switch."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
self._code = code
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._code = code
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._attr_is_on = self._device_number in self._satel.violated_outputs

View File

@@ -118,6 +118,9 @@
"pm25": {
"default": "mdi:molecule"
},
"pm4": {
"default": "mdi:molecule"
},
"power": {
"default": "mdi:flash"
},

View File

@@ -3,15 +3,17 @@
from __future__ import annotations
from datetime import timedelta
from http import HTTPStatus
import logging
from aiosenz import SENZAPI, Thermostat
from httpx import RequestError
from httpx import HTTPStatusError, RequestError
import jwt
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, httpx_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -32,9 +34,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Set up SENZ from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
@@ -57,8 +60,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
account = await senz_api.get_account()
except HTTPStatusError as err:
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="config_entry_auth_failed",
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
except RequestError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
hass,
@@ -71,16 +87,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return unload_ok
async def async_migrate_entry(
hass: HomeAssistant, config_entry: SENZConfigEntry
) -> bool:
"""Migrate old entry."""
# Use sub(ject) from access_token as unique_id
if config_entry.version == 1 and config_entry.minor_version == 1:
token = jwt.decode(
config_entry.data["token"]["access_token"],
options={"verify_signature": False},
)
uid = token["sub"]
hass.config_entries.async_update_entry(
config_entry, unique_id=uid, minor_version=2
)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True

View File

@@ -12,24 +12,23 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ climate entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
)

View File

@@ -1,7 +1,16 @@
"""Config flow for nVent RAYCHEM SENZ."""
from collections.abc import Mapping
import logging
from typing import Any
import jwt
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
@@ -12,6 +21,8 @@ class OAuth2FlowHandler(
):
"""Config flow to handle SENZ OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@@ -23,3 +34,49 @@ class OAuth2FlowHandler(
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": "restapi offline_access"}
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create or update the config entry."""
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
uid = token["sub"]
await self.async_set_unique_id(uid)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=data
)
self._abort_if_unique_id_configured()
return await super().async_oauth_create_entry(data)

View File

@@ -3,10 +3,9 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from . import SENZConfigEntry
TO_REDACT = [
"access_token",
@@ -15,13 +14,11 @@ TO_REDACT = [
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: SENZConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = (
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
)
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),

View File

@@ -13,14 +13,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
@@ -45,11 +44,11 @@ SENSORS: tuple[SenzSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS

View File

@@ -1,6 +1,7 @@
{
"config": {
"abort": {
"account_mismatch": "The used account does not match the original account",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
@@ -9,7 +10,9 @@
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]"
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -23,10 +26,20 @@
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The SENZ integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"config_entry_auth_failed": {
"message": "Authentication failed. Please log in again."
},
"config_entry_not_ready": {
"message": "Error while loading the integration."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.2"]
"requirements": ["pysmartthings==3.3.3"]
}

View File

@@ -663,7 +663,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"issues": {

View File

@@ -34,7 +34,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"system_health": {

View File

@@ -219,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._state: AlarmControlPanelState | None = None
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
@@ -244,11 +243,6 @@ class AbstractTemplateAlarmControlPanel(
if (action_config := config.get(action_id)) is not None:
yield (action_id, action_config, supported_feature)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
return self._state
async def _async_handle_restored_state(self) -> None:
if (
(last_state := await self.async_get_last_state()) is not None
@@ -256,14 +250,14 @@ class AbstractTemplateAlarmControlPanel(
and last_state.state in _VALID_STATES
# The trigger might have fired already while we waited for stored data,
# then we should not restore state
and self._state is None
and self._attr_alarm_state is None
):
self._state = AlarmControlPanelState(last_state.state)
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
def _handle_state(self, result: Any) -> None:
# Validate state
if result in _VALID_STATES:
self._state = result
self._attr_alarm_state = result
_LOGGER.debug("Valid state - %s", result)
return
@@ -273,7 +267,7 @@ class AbstractTemplateAlarmControlPanel(
self.entity_id,
", ".join(_VALID_STATES),
)
self._state = None
self._attr_alarm_state = None
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
"""Arm the panel to specified state with supplied script."""
@@ -284,7 +278,7 @@ class AbstractTemplateAlarmControlPanel(
)
if self._attr_assumed_state:
self._state = state
self._attr_alarm_state = state
self.async_write_ha_state()
async def async_alarm_arm_away(self, code: str | None = None) -> None:
@@ -376,7 +370,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
@callback
def _update_state(self, result):
if isinstance(result, TemplateError):
self._state = None
self._attr_alarm_state = None
return
self._handle_state(result)
@@ -386,7 +380,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_state", self._template, None, self._update_state
"_attr_alarm_state", self._template, None, self._update_state
)
super()._async_setup_templates()

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
"iot_class": "local_polling",
"loggers": ["tesla_wall_connector"],
"requirements": ["tesla-wall-connector==1.0.2"]
"requirements": ["tesla-wall-connector==1.1.0"]
}

View File

@@ -237,7 +237,7 @@ class TeslemetryStreamingUpdateEntity(
if self._download_percentage > 1 and self._download_percentage < 100:
self._attr_in_progress = True
self._attr_update_percentage = self._download_percentage
elif self._install_percentage > 1:
elif self._install_percentage > 10:
self._attr_in_progress = True
self._attr_update_percentage = self._install_percentage
else:

View File

@@ -709,6 +709,7 @@ class DPCode(StrEnum):
DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection"
DO_NOT_DISTURB = "do_not_disturb"
DOORBELL_PIC = "doorbell_pic"
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
DOORCONTACT_STATE_2 = "doorcontact_state_2"
DOORCONTACT_STATE_3 = "doorcontact_state_3"

View File

@@ -2,9 +2,7 @@
from __future__ import annotations
from contextlib import suppress
import json
from typing import Any, cast
from typing import Any
from tuya_sharing import CustomerDevice
@@ -17,6 +15,13 @@ from homeassistant.util import dt as dt_util
from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
_REDACTED_DPCODES = {
DPCode.ALARM_MESSAGE,
DPCode.ALARM_MSG,
DPCode.DOORBELL_PIC,
DPCode.MOVEMENT_DETECT_PIC,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: TuyaConfigEntry
@@ -97,34 +102,24 @@ def _async_device_as_dict(
# Gather Tuya states
for dpcode, value in device.status.items():
# These statuses may contain sensitive information, redact these..
if dpcode in {DPCode.ALARM_MESSAGE, DPCode.MOVEMENT_DETECT_PIC}:
if dpcode in _REDACTED_DPCODES:
data["status"][dpcode] = REDACTED
continue
with suppress(ValueError, TypeError):
value = json.loads(value)
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
value = function.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(cast(str, function.values))
data["function"][function.code] = {
"type": function.type,
"value": value,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
value = status_range.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(status_range.values)
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": value,
"value": status_range.values,
}
# Gather information how this Tuya device is represented in Home Assistant

View File

@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads_object
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
@@ -499,11 +500,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
values = self.device.status_range[dpcode].values
# Fetch color data type information
if function_data := json.loads(values):
if function_data := json_loads_object(values):
self._color_data_type = ColorTypeData(
h_type=IntegerTypeData(dpcode, **function_data["h"]),
s_type=IntegerTypeData(dpcode, **function_data["s"]),
v_type=IntegerTypeData(dpcode, **function_data["v"]),
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
)
else:
# If no type is found, use a default one
@@ -770,12 +771,12 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if not (status_data := self.device.status[self._color_data_dpcode]):
return None
if not (status := json.loads(status_data)):
if not (status := json_loads_object(status_data)):
return None
return ColorData(
type_data=self._color_data_type,
h_value=status["h"],
s_value=status["s"],
v_value=status["v"],
h_value=cast(int, status["h"]),
s_value=cast(int, status["s"]),
v_value=cast(int, status["v"]),
)

View File

@@ -5,12 +5,11 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import base64
from dataclasses import dataclass
import json
from typing import Any, Literal, Self, overload
from typing import Any, Literal, Self, cast, overload
from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads
from homeassistant.util.json import json_loads, json_loads_object
from .const import DPCode, DPType
from .util import parse_dptype, remap_value
@@ -88,7 +87,7 @@ class IntegerTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a IntegerTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
return None
return cls(
@@ -111,9 +110,9 @@ class BitmapTypeInformation(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
@dataclass
@@ -125,9 +124,9 @@ class EnumTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a EnumTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {

View File

@@ -61,7 +61,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -30,6 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
entry.runtime_data = pyvlx
connections = None
if (mac := entry.data.get(CONF_MAC)) is not None:
connections = {(dr.CONNECTION_NETWORK_MAC, mac)}
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@@ -43,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
connections=connections,
)
async def on_hass_stop(event):

View File

@@ -14,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -56,37 +56,32 @@ class VeluxCover(VeluxEntity, CoverEntity):
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxCover."""
super().__init__(node, config_entry_id)
# Features common to all covers
self._attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
# Window is the default device class for covers
self._attr_device_class = CoverDeviceClass.WINDOW
if isinstance(node, Awning):
self._attr_device_class = CoverDeviceClass.AWNING
if isinstance(node, Blind):
self._attr_device_class = CoverDeviceClass.BLIND
self._is_blind = True
if isinstance(node, GarageDoor):
self._attr_device_class = CoverDeviceClass.GARAGE
if isinstance(node, Gate):
self._attr_device_class = CoverDeviceClass.GATE
if isinstance(node, RollerShutter):
self._attr_device_class = CoverDeviceClass.SHUTTER
@property
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
if self.current_cover_tilt_position is not None:
supported_features |= (
if isinstance(node, Blind):
self._attr_device_class = CoverDeviceClass.BLIND
self._is_blind = True
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.STOP_TILT
)
return supported_features
@property
def current_cover_position(self) -> int:

View File

@@ -37,9 +37,7 @@ rules:
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: todo
comment: button still needs it
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"requirements": ["pyvesync==3.2.1"]
"requirements": ["pyvesync==3.2.2"]
}

View File

@@ -144,6 +144,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.DOOR,
value_getter=lambda api: api.isValveOpen(),
),
ViCareBinarySensorEntityDescription(
key="ventilation_frost_protection",
translation_key="ventilation_frost_protection",
value_getter=lambda api: api.getHeatExchangerFrostProtectionActive(),
),
)

View File

@@ -35,6 +35,7 @@ CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
VICARE_BAR = "bar"
VICARE_CELSIUS = "celsius"
VICARE_CUBIC_METER = "cubicMeter"
VICARE_KW = "kilowatt"
VICARE_KWH = "kilowattHour"

Some files were not shown because too many files have changed in this diff Show More