Compare commits

...

184 Commits

Author SHA1 Message Date
jbouwh
c3e4676b4c Revert unneeded changes 2025-11-13 07:01:03 +00:00
jbouwh
f852220282 Set member unique ID's during class init 2025-11-13 07:01:03 +00:00
jbouwh
5dd3bf04eb Remove integration domain 2025-11-13 07:01:03 +00:00
jbouwh
b0c2fdc57b Remove invalid import 2025-11-13 07:01:03 +00:00
jbouwh
617d44ffcf Rework with mixin - Light only 2025-11-13 07:01:03 +00:00
jbouwh
8fb8eed1c8 Automatically update the entity propery when a member created, updated or deleted 2025-11-13 07:01:03 +00:00
jbouwh
1ddbd4755b Apply light group icon to all MQTT light schemas 2025-11-13 07:01:02 +00:00
jbouwh
3bd76294dc Allow an MQTT entity to show as a group 2025-11-13 07:01:02 +00:00
jbouwh
bb97822db9 Cleanup 2025-11-13 06:48:59 +00:00
jbouwh
33ffccabd1 Refactor 2025-11-13 06:48:59 +00:00
jbouwh
56de03ce33 Rework private _included_entities attribute 2025-11-13 06:48:59 +00:00
jbouwh
0cbf7002a8 Add docstring 2025-11-13 06:48:59 +00:00
jbouwh
cffceffe04 Move setup code to add_to_platform_finish 2025-11-13 06:48:59 +00:00
jbouwh
253189805e Remove final 2025-11-13 06:48:59 +00:00
jbouwh
2e91725ac0 Use cached_properties 2025-11-13 06:48:58 +00:00
jbouwh
3b54dddc08 Fix attrbute check - make property final 2025-11-13 06:48:58 +00:00
jbouwh
9bc3d83a55 Update docstring 2025-11-13 06:48:58 +00:00
jbouwh
d62a554cbf Remove the need to manually call async_set_included_entities 2025-11-13 06:48:58 +00:00
jbouwh
f071b7cd46 Improve docstring 2025-11-13 06:48:58 +00:00
jbouwh
37f34f6189 Remove _included_entities property 2025-11-13 06:48:58 +00:00
jbouwh
27dc5b6d18 Do not set included entities if no unique IDs are set 2025-11-13 06:48:58 +00:00
jbouwh
0bbc2f49a6 Upfdate docstr 2025-11-13 06:48:58 +00:00
jbouwh
c121fa25e8 Call async_set_included_entities from add_to_platform_finish 2025-11-13 06:48:58 +00:00
jbouwh
660cea8b65 Handle the entity_id attribute in the Entity base class 2025-11-13 06:48:58 +00:00
jbouwh
c7749ebae1 Fix device tracker 2025-11-13 06:48:58 +00:00
jbouwh
a2acb744b3 Use platform name 2025-11-13 06:48:58 +00:00
jbouwh
0d9158689d Fix device tracker state attrs 2025-11-13 06:48:58 +00:00
jbouwh
f85e8d6c1f Also implement as default in base entity 2025-11-13 06:48:58 +00:00
jbouwh
9be4cc5af1 Integrate with base entity component state attributes 2025-11-13 06:48:58 +00:00
jbouwh
a141eedf2c Update docstr 2025-11-13 06:48:58 +00:00
jbouwh
03040c131c Move logic into Entity class 2025-11-13 06:48:58 +00:00
jbouwh
3eef50632c Use platform domain attribute 2025-11-13 06:48:58 +00:00
jbouwh
eff150cd54 Fix typo 2025-11-13 06:48:58 +00:00
jbouwh
6dcc94b0a1 Follow up on code review 2025-11-13 06:48:58 +00:00
jbouwh
7201903877 Implement mixin class and add feature to maintain included entities from unique IDs 2025-11-13 06:48:58 +00:00
jbouwh
5b776307ea Add included_entities attribute to base Entity class 2025-11-13 06:48:57 +00: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
Will Moss
aedd48c298 Improved error handling for oauth2 configuration in toon integration (#156218) 2025-11-11 22:38:46 +01:00
Will Moss
febbb85532 Improved error handling for oauth2 configuration in netatmo integration (#156207) 2025-11-11 22:37:56 +01:00
Franck Nijhof
af67a35b75 Extend base jinja2 extension with hass requirement and tests (#156403) 2025-11-11 22:34:08 +01:00
Will Moss
dd34d458f5 Improved error handling for oauth2 configuration in tesla_fleet integration (#156219) 2025-11-11 22:33:20 +01:00
Will Moss
603d4bcf87 Improved error handling for oauth2 configuration in weheat integration (#156217) 2025-11-11 22:23:56 +01:00
Erik Montnemery
2dadc1f2b3 Fix iskra tests opening sockets (#156374) 2025-11-11 21:18:14 +01:00
epenet
936151fae5 Use dpcode_wrapper in tuya sensor platform (#156277) 2025-11-11 21:16:41 +01:00
wollew
9760eb7f2b Deprecate velux reboot action (#155549)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 20:23:07 +01:00
Erik Montnemery
7851bed00c Fix zimi tests opening sockets (#156382) 2025-11-11 11:48:30 -06:00
wollew
6aba0b20c6 Add Velux initial quality scale assessment (#154615) 2025-11-11 18:46:24 +01:00
Åke Strandberg
cadfed2348 Add diagnostics to SENZ (#156383) 2025-11-11 18:19:37 +01:00
Åke Strandberg
44e2fa6996 Improve handling of OAuth2 implementation unavailable in SENZ (#156381) 2025-11-11 17:42:19 +01:00
Andrew Jackson
d0ff617e17 Transmission Service validation and fixes (#155554) 2025-11-11 17:29:42 +01:00
wollew
8e499569a4 Add reboot button to velux gateway device (#155547)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 17:10:20 +01:00
Åke Strandberg
5e0ebddd6f Add temperature sensor to SENZ integration (#156181) 2025-11-11 16:33:49 +01:00
Artur Pragacz
c0f61f6c2b Improve code quality of music assistant config flow (#156263) 2025-11-11 16:06:54 +01:00
Manu
df60de38b0 Add In party sensor to Xbox integration (#155967) 2025-11-11 16:05:38 +01:00
Manu
cb086bb8e9 Refactor media source platform in Xbox integration (#155925) 2025-11-11 15:01:53 +01:00
Erik Montnemery
ee2e9dc7d6 Fix homewizard tests opening sockets (#156370) 2025-11-11 15:00:18 +01:00
TheJulianJES
85cd3c68b7 Remove redundant Z-Wave binary sensor entity_description arg (#156323) 2025-11-11 15:00:07 +01:00
Erik Montnemery
1b0b6e63f2 Fix squeezebox tests opening sockets (#156373) 2025-11-11 15:50:56 +02:00
Erik Montnemery
12fc79e8d3 Fix google_generative_ai_conversation tests opening sockets (#156371) 2025-11-11 14:33:36 +01:00
Ludovic BOUÉ
ca2e7b9509 Add Matter Eve Shutter device with corresponding fixtures and snapshots (#156296) 2025-11-11 14:07:08 +01:00
Teemu R.
8e8becc43e tplink: handle repeated, unknown thermostat modes gracefully (#156310) 2025-11-11 14:06:29 +01:00
Paul Annekov
dcec6c3dc8 Forbid to choose state in Ukraine Alarm integration (#156183) 2025-11-11 14:05:14 +01:00
Retha Runolfsson
c0e59c4508 Add support for switchbot s20 (#156368) 2025-11-11 13:55:50 +01:00
Erik Montnemery
cd379aadbf Use pytest.mark.freeze_time in sensibo tests (#156348) 2025-11-11 13:52:19 +01:00
antoniocifu
ccdd54b187 Fix support for Hyperion 2.1.1 (#156343)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 13:18:35 +01:00
Marc Mueller
3f22dbaa2e Update pytest to 9.0.0 (#156365) 2025-11-11 13:18:09 +01:00
Retha Runolfsson
c18dc0a9ab Add support for Switchbot Smart thermostat radiator (#155123) 2025-11-11 13:12:39 +01:00
Erik Montnemery
f0e4296d93 Use pytest.mark.freeze_time in sensor tests (#156349) 2025-11-11 13:05:52 +01:00
Erik Montnemery
b3750109c6 Use pytest.mark.freeze_time in playstation_network tests (#156347) 2025-11-11 13:05:38 +01:00
Erik Montnemery
93025c9845 Use pytest.mark.freeze_time in pglab tests (#156346) 2025-11-11 13:05:17 +01:00
Erik Montnemery
df348644b1 Use pytest.mark.freeze_time in openai_conversation tests (#156345) 2025-11-11 13:05:02 +01:00
Erik Montnemery
8749b0d750 Use pytest.mark.freeze_time in smhi tests (#156352) 2025-11-11 13:02:21 +01:00
Erik Montnemery
a6a1519c06 Use pytest.mark.freeze_time in snoo tests (#156353) 2025-11-11 13:02:01 +01:00
Erik Montnemery
3068e19843 Use pytest.mark.freeze_time in telegram_bot tests (#156354) 2025-11-11 13:01:34 +01:00
Erik Montnemery
55feb1e735 Use pytest.mark.freeze_time in tomorrowio tests (#156355) 2025-11-11 13:01:29 +01:00
Erik Montnemery
bb7dc69131 Use pytest.mark.freeze_time in yale_smart_alarm tests (#156359) 2025-11-11 12:06:22 +01:00
Erik Montnemery
aa9003a524 Use pytest.mark.freeze_time in wake_word tests (#156360) 2025-11-11 12:06:12 +01:00
Erik Montnemery
4e9da5249d Use pytest.mark.freeze_time in utility_meter tests (#156361) 2025-11-11 12:05:58 +01:00
Erik Montnemery
f502739df2 Use pytest.mark.freeze_time in zha tests (#156358) 2025-11-11 12:04:59 +01:00
Erik Montnemery
0f2ff29378 Use pytest.mark.freeze_time in sleep_as_android tests (#156351) 2025-11-11 12:04:40 +01:00
Erik Montnemery
2921e7ed3c Use pytest.mark.freeze_time in plaato tests (#156362) 2025-11-11 12:04:31 +01:00
Christopher Fenner
25d44e8d37 Enhance compressor phase with state translations in ViCare integration (#156238) 2025-11-11 11:20:27 +01:00
Will Moss
0a480a26a3 Remove import of config_entry_oauth2_flow in scaffold in favor of direct imports (#156302) 2025-11-11 11:17:31 +01:00
Khole
d5da64dd8d Bump pyhive to 1.0.7 (#156309) 2025-11-11 11:16:11 +01:00
wollew
92adcd8635 add the velux KLF 200 gateway as device (#155434)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 11:13:18 +01:00
Joost Lekkerkerker
ee0c4b15c2 Make certain fields required for subentry flows (#156251) 2025-11-11 09:42:51 +01:00
Erik Montnemery
507f54198e Use pytest.mark.freeze_time in habitica tests (#156332) 2025-11-11 09:37:17 +01:00
epenet
0ed342b433 Use dpcode_wrapper in tuya alarm control panel platform (#156306) 2025-11-11 09:36:09 +01:00
cdnninja
363c86faf3 Add remove entity to vesync (#156213) 2025-11-11 09:35:19 +01:00
dependabot[bot]
095a7ad060 Bump actions/dependency-review-action from 4.8.1 to 4.8.2 (#156322)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 09:34:38 +01:00
Åke Strandberg
ab5981bbbd Use common string for OAuth2 implementation error in myuplink (#156338) 2025-11-11 09:33:59 +01:00
Erik Montnemery
ac2fb53dfd Fix typo in recorder statistics_meta table manager (#156326) 2025-11-11 09:33:30 +01:00
Erik Montnemery
02ff5de1ff Use pytest.mark.freeze_time in ntfy tests (#156336) 2025-11-11 09:33:21 +01:00
Erik Montnemery
5cd5d480d9 Check collation of statistics_meta DB table (#156327) 2025-11-11 09:31:43 +01:00
Erik Montnemery
a3c7d772fc Use pytest.mark.freeze_time in conversation tests (#156329) 2025-11-11 09:29:46 +01:00
micha91
fe0c69dba7 Update aiomusiccast to 0.15 (#156325) 2025-11-11 09:26:16 +01:00
Artur Pragacz
e5365234c3 Add myself as codeowner to music assistant (#156324) 2025-11-11 09:24:09 +01:00
Erik Montnemery
1531175bd3 Use pytest.mark.freeze_time in google tests (#156330) 2025-11-11 09:22:48 +01:00
Erik Montnemery
62add59ff4 Use pytest.mark.freeze_time in google_generative_ai_conversation tests (#156331) 2025-11-11 09:21:52 +01:00
Erik Montnemery
d8daca657b Use pytest.mark.freeze_time in intellifire tests (#156333) 2025-11-11 10:17:58 +02:00
Erik Montnemery
1891da46ea Use pytest.mark.freeze_time in knx tests (#156335) 2025-11-11 08:52:39 +01:00
Marc Mueller
22ae894745 Update pytest-asyncio to 1.3.0 (#156315) 2025-11-10 22:07:02 -08:00
Will Moss
160810c69d Move oauth2_implementation_unavailable string to top level (#156299) 2025-11-11 06:58:24 +01:00
epenet
2ae23b920a Use dpcode_wrapper in tuya siren platform (#156284) 2025-11-10 23:06:14 +01:00
Artur Pragacz
a7edfb082f Move config intents to manager (#154903) 2025-11-10 16:04:25 -06:00
Ludovic BOUÉ
3ac203b05f Add Matter Aqara W100 fixture (#156305)
- Adds JSON fixture file containing Matter node data for the Aqara W100 sensor
- Updates test configuration to include the new fixture in parametrized tests
- Adds snapshot test data for sensor and button entities created by this device
2025-11-10 21:58:18 +01:00
Jan Bouwhuis
7c3eb19fc4 Fix issues() template method returns non active issues (#156274) 2025-11-10 21:56:57 +01:00
kingy444
70c6fac743 Move hunterdouglas_powerview data class to upstream library (#156228)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-10 14:49:00 -06:00
Åke Strandberg
e19d7250d5 Adjust user-facing string for miele (#156280) 2025-11-10 20:42:42 +01:00
Maikel Punie
a850d5dba7 Bump velbusaio to 2025.11.0 (#156293) 2025-11-10 21:25:00 +02:00
Erik Montnemery
0cf0f10654 Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-10 20:14:25 +01:00
Ludovic BOUÉ
8429f154ca Fix status checks in Matter binary sensors (#156276)
This PR fixes bitmap bit checking logic in Matter binary sensors by replacing equality comparisons with bitwise AND operations. The changes correct how the integration checks if specific bits are set in bitmap fields.

Key changes:

Changed equality checks (==) to bitwise AND operations (&) for checking bitmap bits
Wrapped bitwise operations with bool() to ensure boolean return values
Applied fixes consistently across PumpStatus, DishwasherAlarm, and RefrigeratorAlarm bitmaps
2025-11-10 19:45:17 +01:00
Assaf Inbal
7b4f5ad362 Ituran: Don't cache properties (#156281) 2025-11-10 19:24:58 +02:00
David Rapan
583b439557 Add Shelly number translation (#156156)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-10 19:15:16 +02:00
Michael Hansen
05922de102 Always chunk Wyoming TTS audio (#156079) 2025-11-10 10:40:45 -05:00
Khole
7675a44b90 Hive: Remove Alarm Support (#156184) 2025-11-10 16:32:38 +01:00
Simone Chemelli
1e4d645683 Fix config flow reconfigure for Comelit (#156193) 2025-11-10 16:28:47 +01:00
Glenn Vandeuren (aka Iondependent)
b5ae04605a Add climate platform for niko_home_control (#138087)
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com>
2025-11-10 16:27:59 +01:00
Manu
2240d6b94c Enable trophy sensors also for friends in PlayStation Network integration (#156106) 2025-11-10 16:18:15 +01:00
Foscam-wangzhengyu
d1536ee636 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:14:57 +01:00
J. Nick Koston
8a926add7a Bump PySwitchbot to 0.73.0 (#156266) 2025-11-10 10:10:23 -05:00
J. Nick Koston
31f769900a Bump aiopvapi to 3.3.0 (#156268) 2025-11-10 10:06:58 -05:00
cdnninja
33ad777664 Add temp sensor to vesync humidifers (#155637)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:03:16 +01:00
Bouwe Westerdijk
59a4e4a337 Add Plugwise Adam zone profile select (#156262) 2025-11-10 16:00:26 +01:00
Andre Lengwenus
66a39933b0 Remove translations for non-existing service (#156265) 2025-11-10 15:53:00 +01:00
Heindrich Paul
ad395e3bba Add delay clean time support to Tuya integration for cat litter boxes (#156053) 2025-11-10 15:48:00 +01:00
hanwg
cfc6f2c229 Remove yaml in tests for Telegram polling bot (#156257) 2025-11-10 15:30:06 +01:00
Andrew Jackson
63aa41c766 Bump aiomealie to 1.1.0, adding recipe rating (#156256) 2025-11-10 15:28:45 +01:00
Tom Matheussen
037e0e93d3 Cleanup binary sensor platform for Satel Integra (#155915)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 15:13:49 +01:00
epenet
db8b5865b3 Improve Tuya event tests (#156259) 2025-11-10 15:03:23 +01:00
epenet
bd2ccc6672 Add tests for tuya button (#156252) 2025-11-10 14:54:51 +01:00
Joost Lekkerkerker
bb63d40cdf Bump pySmartThings to 3.3.2 (#156250) 2025-11-10 14:53:29 +01:00
Ludovic BOUÉ
65285b8885 Fix Matter ValveFault attribute handling (#156258)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-10 14:45:13 +01:00
Denis Shulyaka
326b8f2b4f Add AI task for Anthropic (#156221) 2025-11-10 14:01:28 +01:00
Heindrich Paul
9f3df52fcc Added light support to cat litter boxes (#156051) 2025-11-10 13:57:54 +01:00
wollew
875838c277 adjust naming of velux light entities according to guidelines (#155850) 2025-11-10 13:55:17 +01:00
epenet
adaafd1fda Use dpcode_wrapper in tuya binary sensor platform (#156247)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 13:54:09 +01:00
Heindrich Paul
50c5efddaa Add buttons for cat litter box devices (#156050) 2025-11-10 13:50:40 +01:00
epenet
c4be054161 Adjust Tuya DPCodeBooleanWrapper inheritance (#156255) 2025-11-10 13:39:09 +01:00
Bouwe Westerdijk
61186356f3 Refresh test-fixtures for Plugwise (#156253) 2025-11-10 13:35:24 +01:00
Will Moss
9d60a19440 Improved error handling for oauth2 configuration in volvo integration (#156215) 2025-11-10 13:17:48 +01:00
epenet
108c212855 Use dpcode_wrapper in tuya button platform (#156237) 2025-11-10 12:58:42 +01:00
Erik Montnemery
ae8db81c4e Use pytest.mark.freeze_time in ambient_network tests (#156241) 2025-11-10 12:50:43 +01:00
dotvav
51c970d1d0 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-10 12:49:40 +01:00
398 changed files with 24850 additions and 13321 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 1 CACHE_VERSION: 2
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12" HA_SHORT_VERSION: "2025.12"
@@ -622,7 +622,7 @@ jobs:
steps: steps:
- *checkout - *checkout
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks

4
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind /homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys /homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant /homeassistant/components/music_assistant/ @music-assistant @arturpragacz
/tests/components/music_assistant/ @music-assistant /tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/mutesync/ @currentoor /homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor /tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core /homeassistant/components/my/ @home-assistant/core

View File

@@ -25,7 +25,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
) )
PLATFORMS = (Platform.CONVERSATION,) PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]

View File

@@ -0,0 +1,80 @@
"""AI Task integration for Anthropic."""
from __future__ import annotations
from json import JSONDecodeError
import logging
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .entity import AnthropicBaseLLMEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[AnthropicTaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class AnthropicTaskEntity(
ai_task.AITaskEntity,
AnthropicBaseLLMEntity,
):
"""Anthropic AI Task entity."""
_attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)

View File

@@ -53,6 +53,7 @@ from .const import (
CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION, CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DOMAIN, DOMAIN,
NON_THINKING_MODELS, NON_THINKING_MODELS,
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
RECOMMENDED_OPTIONS = { RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
} }
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._async_abort_entries_match(user_input) self._async_abort_entries_match(user_input)
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[ subentries=[
{ {
"subentry_type": "conversation", "subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS, "data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME, "title": DEFAULT_CONVERSATION_NAME,
"unique_id": None, "unique_id": None,
} },
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
], ],
) )
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]: ) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration.""" """Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler} return {
"conversation": ConversationSubentryFlowHandler,
"ai_task_data": ConversationSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow): class ConversationSubentryFlowHandler(ConfigSubentryFlow):
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult: ) -> SubentryFlowResult:
"""Add a subentry.""" """Add a subentry."""
self.options = RECOMMENDED_OPTIONS.copy() if self._subentry_type == "ai_task_data":
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init() return await self.async_step_init()
async def async_step_reconfigure( async def async_step_reconfigure(
@@ -198,10 +215,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if self._is_new: if self._is_new:
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( if self._subentry_type == "ai_task_data":
str default_name = DEFAULT_AI_TASK_NAME
) else:
default_name = DEFAULT_CONVERSATION_NAME
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
if self._subentry_type == "conversation":
step_schema.update( step_schema.update(
{ {
vol.Optional(CONF_PROMPT): TemplateSelector(), vol.Optional(CONF_PROMPT): TemplateSelector(),
@@ -210,12 +230,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
): SelectSelector( ): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True) SelectSelectorConfig(options=hass_apis, multiple=True)
), ),
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
): bool,
} }
) )
step_schema[
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
)
] = bool
if user_input is not None: if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API): if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None) user_input.pop(CONF_LLM_HASS_API, None)
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith(tuple(NON_THINKING_MODELS)): if not model.startswith(tuple(NON_THINKING_MODELS)):
step_schema[ step_schema[
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET) vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
] = NumberSelector( ] = vol.All(
NumberSelector(
NumberSelectorConfig( NumberSelectorConfig(
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS) min=0,
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
) )
),
vol.Coerce(int),
) )
else: else:
self.options.pop(CONF_THINKING_BUDGET, None) self.options.pop(CONF_THINKING_BUDGET, None)

View File

@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "Claude conversation" DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"

View File

@@ -1,17 +1,24 @@
"""Base entity for Anthropic.""" """Base entity for Anthropic."""
import base64
from collections.abc import AsyncGenerator, Callable, Iterable from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json import json
from mimetypes import guess_file_type
from pathlib import Path
from typing import Any from typing import Any
import anthropic import anthropic
from anthropic import AsyncStream from anthropic import AsyncStream
from anthropic.types import ( from anthropic.types import (
Base64ImageSourceParam,
Base64PDFSourceParam,
CitationsDelta, CitationsDelta,
CitationsWebSearchResultLocation, CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam, CitationWebSearchResultLocationParam,
ContentBlockParam, ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta, InputJSONDelta,
MessageDeltaUsage, MessageDeltaUsage,
MessageParam, MessageParam,
@@ -37,6 +44,9 @@ from anthropic.types import (
ThinkingConfigDisabledParam, ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam, ThinkingConfigEnabledParam,
ThinkingDelta, ThinkingDelta,
ToolChoiceAnyParam,
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam, ToolParam,
ToolResultBlockParam, ToolResultBlockParam,
ToolUnionParam, ToolUnionParam,
@@ -50,13 +60,16 @@ from anthropic.types import (
WebSearchToolResultError, WebSearchToolResultError,
) )
from anthropic.types.message_create_params import MessageCreateParamsStreaming from anthropic.types.message_create_params import MessageCreateParamsStreaming
import voluptuous as vol
from voluptuous_openapi import convert from voluptuous_openapi import convert
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from . import AnthropicConfigEntry from . import AnthropicConfigEntry
from .const import ( from .const import (
@@ -321,6 +334,7 @@ def _convert_content(
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog, chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent], stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> AsyncGenerator[ ) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]: ]:
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
input="", input="",
) )
current_tool_args = "" current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, TextBlock): elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block first_block
@@ -471,6 +495,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
first_block = True first_block = True
elif isinstance(response, RawContentBlockDeltaEvent): elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta): if isinstance(response.delta, InputJSONDelta):
if (
current_tool_block is not None
and current_tool_block["name"] == output_tool
):
content_details.citation_details[-1].length += len(
response.delta.partial_json
)
yield {"content": response.delta.partial_json}
else:
current_tool_args += response.delta.partial_json current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta): elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text) content_details.citation_details[-1].length += len(response.delta.text)
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
content_details.add_citation(response.delta.citation) content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent): elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None: if current_tool_block is not None:
if current_tool_block["name"] == output_tool:
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args current_tool_block["input"] = tool_args
yield { yield {
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
async def _async_handle_chat_log( async def _async_handle_chat_log(
self, self,
chat_log: conversation.ChatLog, chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None: ) -> None:
"""Generate an answer for the chat log.""" """Generate an answer for the chat log."""
options = self.subentry.data options = self.subentry.data
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
} }
tools.append(web_search) tools.append(web_search)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
"Last message must be a user message to add attachments"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
TextBlockParam(type="text", text=last_message["content"])
]
last_message["content"].extend( # type: ignore[union-attr]
await async_prepare_files_for_prompt(
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
)
)
if structure and structure_name:
structure_name = slugify(structure_name)
if model_args["thinking"]["type"] == "disabled":
if not tools:
# Simplest case: no tools and no extended thinking
# Add a tool and force its use
model_args["tool_choice"] = ToolChoiceToolParam(
type="tool",
name=structure_name,
)
else:
# Second case: tools present but no extended thinking
# Allow the model to use any tool but not text response
# The model should know to use the right tool by its description
model_args["tool_choice"] = ToolChoiceAnyParam(
type="any",
)
else:
# Extended thinking is enabled. With extended thinking, we cannot
# force tool use or disable text responses, so we add a hint to the
# system prompt instead. With extended thinking, the model should be
# smart enough to use the tool.
model_args["tool_choice"] = ToolChoiceAutoParam(
type="auto",
)
if isinstance(model_args["system"], str):
model_args["system"] = [
TextBlockParam(type="text", text=model_args["system"])
]
model_args["system"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
)
)
tools.append(
ToolParam(
name=structure_name,
description="Use this tool to reply to the user",
input_schema=convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
)
)
if tools: if tools:
model_args["tools"] = tools model_args["tools"] = tools
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
content content
async for content in chat_log.async_add_delta_content_stream( async for content in chat_log.async_add_delta_content_stream(
self.entity_id, self.entity_id,
_transform_stream(chat_log, stream), _transform_stream(
chat_log,
stream,
output_tool=structure_name if structure else None,
),
) )
] ]
) )
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
if not chat_log.unresponded_tool_results: if not chat_log.unresponded_tool_results:
break break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
Caller needs to ensure that the files are allowed.
"""
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
content: list[ImageBlockParam | DocumentBlockParam] = []
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
ImageBlockParam(
type="image",
source=Base64ImageSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
elif mime_type.startswith("application/pdf"):
content.append(
DocumentBlockParam(
type="document",
source=Base64PDFSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
return content
return await hass.async_add_executor_job(append_files_to_content)

View File

@@ -18,6 +18,49 @@
} }
}, },
"config_subentries": { "config_subentries": {
"ai_task_data": {
"abort": {
"entry_not_loaded": "[%key:component::anthropic::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "AI task",
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
},
"step": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
},
"init": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::anthropic::config_subentries::conversation::step::init::data::recommended%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::init::title%]"
},
"model": {
"data": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
"data_description": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::model::title%]"
}
}
},
"conversation": { "conversation": {
"abort": { "abort": {
"entry_not_loaded": "Cannot add things while the configuration is disabled.", "entry_not_loaded": "Cannot add things while the configuration is disabled.",
@@ -46,7 +89,8 @@
}, },
"data_description": { "data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template." "prompt": "Instruct how the LLM should respond. This can be a template."
} },
"title": "Basic settings"
}, },
"model": { "model": {
"data": { "data": {

View File

@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
@@ -175,19 +168,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reconfiguration of the device.""" """Handle reconfiguration of the device."""
reconfigure_entry = self._get_reconfigure_entry() reconfigure_entry = self._get_reconfigure_entry()
if not user_input: errors: dict[str, str] = {}
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_RECONFIGURE
)
if user_input is not None:
updated_host = user_input[CONF_HOST] updated_host = user_input[CONF_HOST]
self._async_abort_entries_match({CONF_HOST: updated_host}) self._async_abort_entries_match({CONF_HOST: updated_host})
errors: dict[str, str] = {}
try: try:
await validate_input(self.hass, user_input) data_to_validate = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
await validate_input(self.hass, data_to_validate)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
@@ -198,13 +193,30 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
reconfigure_entry, data_updates={CONF_HOST: updated_host} reconfigure_entry, data_updates=data_updates
)
schema = vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): cv.string,
vol.Required(
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
): cv.port,
vol.Optional(CONF_PIN): cv.string,
}
) )
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=STEP_RECONFIGURE, data_schema=schema,
errors=errors, errors=errors,
) )

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import logging import logging
from typing import Literal from typing import Any, Literal
from hassil.recognize import RecognizeResult from hassil.recognize import RecognizeResult
import voluptuous as vol import voluptuous as vol
@@ -21,6 +21,7 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@@ -52,6 +53,8 @@ from .const import (
DATA_COMPONENT, DATA_COMPONENT,
DOMAIN, DOMAIN,
HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS, SERVICE_PROCESS,
SERVICE_RELOAD, SERVICE_RELOAD,
ConversationEntityFeature, ConversationEntityFeature,
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {}) manager = get_agent_manager(hass)
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {}) hass_config_path = hass.config.path()
) config_intents = _get_config_intents(config, hass_config_path)
manager.update_config_intents(config_intents)
await async_setup_default_agent(hass, entity_component)
async def handle_process(service: ServiceCall) -> ServiceResponse: async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands.""" """Parse text into commands."""
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None: async def handle_reload(service: ServiceCall) -> None:
"""Reload intents.""" """Reload intents."""
agent = get_agent_manager(hass).default_agent language = service.data.get(ATTR_LANGUAGE)
if language is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if conf is not None:
config_intents = _get_config_intents(conf, hass_config_path)
manager.update_config_intents(config_intents)
agent = manager.default_agent
if agent is not None: if agent is not None:
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) await agent.async_reload(language=language)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry) return await hass.data[DATA_COMPONENT].async_setup_entry(entry)

View File

@@ -147,6 +147,7 @@ class AgentManager:
self.hass = hass self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {} self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None self.default_agent: DefaultAgent | None = None
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = [] self.triggers_details: list[TriggerDetails] = []
@callback @callback
@@ -199,9 +200,16 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None: async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent.""" """Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details) agent.update_triggers(self.triggers_details)
self.default_agent = agent self.default_agent = agent
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE: def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger.""" """Register a trigger."""
self.triggers_details.append(trigger_details) self.triggers_details.append(trigger_details)

View File

@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity.""" """Supported features of the conversation entity."""
CONTROL = 1 CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"

View File

@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog from .chat_log import AssistantContent, ChatLog
from .const import DOMAIN, ConversationEntityFeature from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match" METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object() ERROR_SENTINEL = object()
@@ -202,10 +205,9 @@ class IntentCache:
async def async_setup_default_agent( async def async_setup_default_agent(
hass: HomeAssistant, hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity], entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None: ) -> None:
"""Set up entity registry listener for the default agent.""" """Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass, config_intents) agent = DefaultAgent(hass)
await entity_component.async_add_entities([agent]) await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent) await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant" _attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL _attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the default agent.""" """Initialize the default agent."""
self.hass = hass self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {} self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock() self._load_intents_lock = asyncio.Lock()
# intent -> [sentences] # Intents from common conversation config
self._config_intents: dict[str, Any] = config_intents self._config_intents: dict[str, Any] = {}
# Sentences that will trigger a callback (skipping intent recognition) # Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = [] self._triggers_details: list[TriggerDetails] = []
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache # Intents have changed, so we must clear the cache
self._intent_cache.clear() self._intent_cache.clear()
@callback
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._config_intents = intents
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
async def async_prepare(self, language: str | None = None) -> None: async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language.""" """Load intents for a language."""
if language is None: if language is None:
@@ -1159,32 +1169,9 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path, custom_sentences_path,
) )
# Load sentences from HA config for default language only
if self._config_intents and (
self.hass.config.language in (language, language_variant)
):
hass_config_path = self.hass.config.path()
merge_dict( merge_dict(
intents_dict, intents_dict,
{ self._config_intents,
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in self._config_intents.items()
}
},
)
_LOGGER.debug(
"Loaded intents from configuration.yaml",
) )
if not intents_dict: if not intents_dict:

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
UnitOfEnergy, UnitOfEnergy,
UnitOfPower,
UnitOfVolume, UnitOfVolume,
) )
from homeassistant.core import HomeAssistant, callback, valid_entity_id 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, ...]] = { ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: 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( ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
) )
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
GAS_USAGE_DEVICE_CLASSES = ( GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY, sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS, 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 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: if issue_type == GAS_UNIT_ERROR:
return { return {
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]), "energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
@callback @callback
def _async_validate_usage_stat( def _async_validate_stat_common(
hass: HomeAssistant, hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str, stat_id: str,
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
allowed_units: Mapping[str, Sequence[str]], allowed_units: Mapping[str, Sequence[str]],
unit_error: str, unit_error: str,
issues: ValidationIssues, issues: ValidationIssues,
) -> None: check_negative: bool = False,
"""Validate a statistic.""" ) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
if stat_id not in metadata: if stat_id not in metadata:
issues.add_issue(hass, "statistics_not_defined", stat_id) issues.add_issue(hass, "statistics_not_defined", stat_id)
has_entity_source = valid_entity_id(stat_id) has_entity_source = valid_entity_id(stat_id)
if not has_entity_source: if not has_entity_source:
return return None
entity_id = stat_id entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id): if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id) issues.add_issue(hass, "recorder_untracked", entity_id)
return return None
if (state := hass.states.get(entity_id)) is None: if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id) issues.add_issue(hass, "entity_not_defined", entity_id)
return return None
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state) issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return return None
try: try:
current_value: float | None = float(state.state) current_value: float | None = float(state.state)
except ValueError: except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state) 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) issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
device_class = state.attributes.get(ATTR_DEVICE_CLASS) 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, []): if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit) 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) state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
allowed_state_classes = [ allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
issues.add_issue(hass, unit_error, entity_id, unit) 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 @callback
def _async_validate_cost_stat( def _async_validate_cost_stat(
hass: HomeAssistant, hass: HomeAssistant,
@@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
) )
) )
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,
)
)
elif source["type"] == "gas": elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"]) wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append( validate_calls.append(

View File

@@ -116,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None is_open_wdr = None
is_open_hdr = None is_open_hdr = None
reserve3 = product_info.get("reserve4") reserve3 = product_info.get("reserve4")
model = product_info.get("model")
model_int = int(model) if model is not None else 7002
if model_int > 7001:
reserve3_int = int(reserve3) if reserve3 is not None else 0 reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256)) supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
if supports_wdr_adjustment_val: if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode() ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 mode = (
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
)
is_open_wdr = bool(int(mode)) is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val: elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode() ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 mode = (
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
)
is_open_hdr = bool(int(mode)) is_open_hdr = bool(int(mode))
else:
supports_wdr_adjustment_val = False
supports_hdr_adjustment_val = False
ret_sw, software_capabilities = self.session.getSWCapabilities() ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = ( supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32) bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0 if ret_sw == 0

View File

@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate", sidebar_title="climate",
sidebar_default_visible=False, 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") async_register_built_in_panel(hass, "profile")

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "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

@@ -1,77 +0,0 @@
"""Support for the Hive alarm."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HiveConfigEntry
from .entity import HiveEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=15)
HIVETOHA = {
"home": AlarmControlPanelState.DISARMED,
"asleep": AlarmControlPanelState.ARMED_NIGHT,
"away": AlarmControlPanelState.ARMED_AWAY,
"sos": AlarmControlPanelState.TRIGGERED,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: HiveConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
hive = entry.runtime_data
if devices := hive.session.deviceList.get("alarm_control_panel"):
async_add_entities(
[HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True
)
class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
"""Representation of a Hive alarm."""
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_NIGHT
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.TRIGGER
)
_attr_code_arm_required = False
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self.hive.alarm.setMode(self.device, "home")
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.hive.alarm.setMode(self.device, "asleep")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.hive.alarm.setMode(self.device, "away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.hive.alarm.setMode(self.device, "sos")
async def async_update(self) -> None:
"""Update all Node data from Hive."""
await self.hive.session.updateData(self.device)
self.device = await self.hive.alarm.getAlarm(self.device)
self._attr_available = self.device["deviceData"].get("online")
if self._attr_available:
if self.device["status"]["state"]:
self._attr_alarm_state = AlarmControlPanelState.TRIGGERED
else:
self._attr_alarm_state = HIVETOHA[self.device["status"]["mode"]]

View File

@@ -11,7 +11,6 @@ CONFIG_ENTRY_VERSION = 1
DEFAULT_NAME = "Hive" DEFAULT_NAME = "Hive"
DOMAIN = "hive" DOMAIN = "hive"
PLATFORMS = [ PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.LIGHT, Platform.LIGHT,
@@ -20,7 +19,6 @@ PLATFORMS = [
Platform.WATER_HEATER, Platform.WATER_HEATER,
] ]
PLATFORM_LOOKUP = { PLATFORM_LOOKUP = {
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
Platform.BINARY_SENSOR: "binary_sensor", Platform.BINARY_SENSOR: "binary_sensor",
Platform.CLIMATE: "climate", Platform.CLIMATE: "climate",
Platform.LIGHT: "light", Platform.LIGHT: "light",

View File

@@ -9,5 +9,5 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["apyhiveapi"], "loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.6"] "requirements": ["pyhive-integration==1.0.7"]
} }

View File

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

View File

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

View File

@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
self._update_native_value(status) 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.""" """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: match self.device_class:
case SensorDeviceClass.TIMESTAMP: case SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dt_util.utcnow() + timedelta( self._attr_native_value = dt_util.utcnow() + timedelta(

View File

@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}" "message": "Error obtaining data from the API: {error}"
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"pause_program": { "pause_program": {
"message": "Error pausing program: {error}" "message": "Error pausing program: {error}"

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods.""" """Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800 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( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system", "integration_type": "system",
"requirements": [ "requirements": [
"universal-silabs-flasher==0.0.37", "universal-silabs-flasher==0.1.0",
"ha-silabs-firmware-client==0.3.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 # Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = [] BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
device=self._current_device, device=self._current_device,
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type, 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, progress_callback=self._update_progress,
domain=self._config_entry.domain, domain=self._config_entry.domain,
) )

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict 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 contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum 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( 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: ) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device.""" """Probe the running firmware on a SiLabs device."""
flasher = Flasher( flasher = Flasher(
device=device, device=device,
**( probe_methods=tuple(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]} (m.as_flasher_application_type(), baudrate)
if probe_methods for m, baudrate in application_probe_methods
else {} ),
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( 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: ) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device.""" """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: if fw_info is None:
return None return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str, device: str,
fw_data: bytes, fw_data: bytes,
expected_installed_firmware_type: ApplicationType, 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, progress_callback: Callable[[int, int], None] | None = None,
*, *,
domain: str = DOMAIN, domain: str = DOMAIN,
) -> FirmwareInfo: ) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.""" """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): async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device) firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info) _LOGGER.debug("Identified firmware info: %s", firmware_info)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher( flasher = Flasher(
device=device, device=device,
probe_methods=( probe_methods=tuple(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), (m.as_flasher_application_type(), baudrate)
ApplicationType.EZSP.as_flasher_application_type(), for m, baudrate in application_probe_methods
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
), ),
bootloader_reset=tuple( bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods 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( probed_firmware_info = await probe_silabs_firmware_info(
device, 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: 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 ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.usb import ( from homeassistant.components.usb import (
usb_service_info_from_device, usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext 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]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
placeholders = { placeholders = {

View File

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

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods.""" """Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW] 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( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this # 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 # Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if ( if (

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity.""" """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__( def __init__(
self, self,

View File

@@ -4,6 +4,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiopvapi.resources.model import PowerviewData from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.rooms import Rooms from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewConfigEntry, PowerviewEntryData from .model import PowerviewConfigEntry, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_connect_hub from .util import async_connect_hub
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1

View File

@@ -8,6 +8,7 @@ import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub from aiopvapi.hub import Hub
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import HUB_EXCEPTIONS from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def _async_execute_move(self, move: ShadePosition) -> None: async def _async_execute_move(self, move: ShadePosition) -> None:
"""Execute a move that can affect multiple positions.""" """Execute a move that can affect multiple positions."""
_LOGGER.debug("Move request %s: %s", self.name, move) _LOGGER.debug("Move request %s: %s", self.name, move)
# Store the requested positions so subsequent move
# requests contain the secondary shade positions
self.data.update_shade_position(self._shade.id, move)
async with self.coordinator.radio_operation_lock: async with self.coordinator.radio_operation_lock:
response = await self._shade.move(move) response = await self._shade.move(move)
_LOGGER.debug("Move response %s: %s", self.name, response) _LOGGER.debug("Move response %s: %s", self.name, response)
# Process the response from the hub (including new positions)
self.data.update_shade_position(self._shade.id, response)
async def _async_set_cover_position(self, target_hass_position: int) -> None: async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position.""" """Move the shade to a position."""
target_hass_position = self._clamp_cover_limit(target_hass_position) target_hass_position = self._clamp_cover_limit(target_hass_position)

View File

@@ -3,6 +3,7 @@
import logging import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition from aiopvapi.resources.shade import BaseShade, ShadePosition
from aiopvapi.resources.shade_data import PowerviewShadeData
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -18,6 +18,6 @@
}, },
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiopvapi"], "loggers": ["aiopvapi"],
"requirements": ["aiopvapi==3.2.1"], "requirements": ["aiopvapi==3.3.0"],
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."] "zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
} }

View File

@@ -1,80 +0,0 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from dataclasses import fields
from typing import Any
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .util import async_map_data_by_id
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
"""Copy position data from source to target for None values only."""
for field in POSITION_FIELDS:
if (value := getattr(source, field.name)) is not None:
setattr(target, field.name, value)
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
self._shade_group_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
return self._raw_data_by_id[shade_id]
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
"""Get data for all shades."""
return self._raw_data_by_id
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_group_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
shade_position = ShadePosition()
# If we have the group data, use it to populate the initial position
if shade := self._shade_group_data_by_id.get(shade_id):
copy_position_data(shade.current_position, shade_position)
self.positions[shade_id] = shade_position
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
data = self._shade_group_data_by_id[shade_id]
copy_position_data(data.current_position, self.get_shade_position(data.id))
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions (self.positions)
as the data may be stale. update_from_group_data
with a shade_id will update a specific shade
from the group data.
"""
self._shade_group_data_by_id = shade_data.processed
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
"""Update a single shades position."""
copy_position_data(new_position, self.get_shade_position(shade_id))
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.get_shade_position(shade_id).velocity = shade_data.velocity

View File

@@ -2,25 +2,15 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.hub import Hub from aiopvapi.hub import Hub
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .model import PowerviewAPI, PowerviewDeviceInfo from .model import PowerviewAPI, PowerviewDeviceInfo
@callback
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_connect_hub( async def async_connect_hub(
hass: HomeAssistant, address: str, api_version: int | None = None hass: HomeAssistant, address: str, api_version: int | None = None
) -> PowerviewAPI: ) -> PowerviewAPI:

View File

@@ -13,6 +13,7 @@ from typing import Any
from aiohttp import web from aiohttp import web
from hyperion import client from hyperion import client
from hyperion.const import ( from hyperion.const import (
KEY_DATA,
KEY_IMAGE, KEY_IMAGE,
KEY_IMAGE_STREAM, KEY_IMAGE_STREAM,
KEY_LEDCOLORS, KEY_LEDCOLORS,
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
"""Update Hyperion components.""" """Update Hyperion components."""
if not img: if not img:
return return
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE) # Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL): if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
return return
async with self._image_cond: async with self._image_cond:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker.""" """Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker") super().__init__(coordinator, license_plate, "device_tracker")
@cached_property @property
def latitude(self) -> float | None: def latitude(self) -> float | None:
"""Return latitude value of the device.""" """Return latitude value of the device."""
return self.vehicle.gps_coordinates[0] return self.vehicle.gps_coordinates[0]
@cached_property @property
def longitude(self) -> float | None: def longitude(self) -> float | None:
"""Return longitude value of the device.""" """Return longitude value of the device."""
return self.vehicle.gps_coordinates[1] return self.vehicle.gps_coordinates[1]

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def native_value(self) -> StateType | datetime: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware() await self.coordinator.device.update_firmware()
while ( while (
update_progress := await self.coordinator.device.get_firmware() 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: if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error() _raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -94,28 +94,6 @@
} }
}, },
"services": { "services": {
"address_to_device_id": {
"description": "Converts an LCN address into a device ID.",
"fields": {
"host": {
"description": "Host name as given in the integration panel.",
"name": "Host name"
},
"id": {
"description": "Module or group number of the target.",
"name": "Module or group ID"
},
"segment_id": {
"description": "Segment number of the target.",
"name": "Segment ID"
},
"type": {
"description": "Module type of the target.",
"name": "Type"
}
},
"name": "Address to device ID"
},
"dyn_text": { "dyn_text": {
"description": "Sends dynamic text to LCN-GTxD displays.", "description": "Sends dynamic text to LCN-GTxD displays.",
"fields": { "fields": {

View File

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

View File

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

View File

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

View File

@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled # DeviceFault or SupplyFault bit enabled
device_to_ha={ device_to_ha=lambda x: bool(
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True, x
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True, & (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False, | clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False, )
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False, ),
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
}.get,
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
required_attributes=( required_attributes=(
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning", key="PumpStatusRunning",
translation_key="pump_running", translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING, device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x x
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning & clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow", translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door", translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -425,9 +421,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_general_fault", translation_key="valve_fault_general_fault",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # GeneralFault bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -443,9 +440,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_blocked", translation_key="valve_fault_blocked",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # Blocked bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -461,9 +459,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_leaking", translation_key="valve_fault_leaking",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # Leaking bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -478,8 +477,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door", translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiomealie==1.0.1"] "requirements": ["aiomealie==1.1.0"]
} }

View File

@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program", "cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program", "maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode", "normal_operation_mode": "Normal operation mode",
"own_program": "Own program" "own_program": "Program"
} }
}, },
"remaining_time": { "remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted." "message": "Invalid device targeted."
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"set_program_error": { "set_program_error": {
"message": "'Set program' action failed: {status} / {message}" "message": "'Set program' action failed: {status} / {message}"

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "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,11 +61,13 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)]) async_add_entities([MobileAppBinarySensor(data, config_entry)])
config_entry.async_on_unload(
async_dispatcher_connect( async_dispatcher_connect(
hass, hass,
f"{DOMAIN}_{ENTITY_TYPE}_register", f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration, handle_sensor_registration,
) )
)
class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity):

View File

@@ -72,11 +72,13 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)]) async_add_entities([MobileAppSensor(data, config_entry)])
config_entry.async_on_unload(
async_dispatcher_connect( async_dispatcher_connect(
hass, hass,
f"{DOMAIN}_{ENTITY_TYPE}_register", f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration, handle_sensor_registration,
) )
)
class MobileAppSensor(MobileAppEntity, RestoreSensor): class MobileAppSensor(MobileAppEntity, RestoreSensor):

View File

@@ -73,6 +73,7 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic", "fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update", "frc_upd": "force_update",
"g_tpl": "green_template", "g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic", "hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template", "hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic", "hs_stat_t": "hs_state_topic",

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import ( from .const import (
CONF_COMMAND_TOPIC, CONF_COMMAND_TOPIC,
CONF_ENCODING, CONF_ENCODING,
CONF_GROUP,
CONF_QOS, CONF_QOS,
CONF_RETAIN, CONF_RETAIN,
CONF_STATE_TOPIC, CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = { SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
} }
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE) MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template" CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic" CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template" CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -79,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT, CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING, CONF_ENCODING,
CONF_ENTITY_PICTURE, CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION, CONF_HW_VERSION,
CONF_IDENTIFIERS, CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TEMPLATE,
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class", "device_class",
"device_info", "device_info",
"entity_category", "entity_category",
"entity_id",
"entity_picture", "entity_picture",
"entity_registry_enabled_default", "entity_registry_enabled_default",
"extra_state_attributes", "extra_state_attributes",
@@ -475,6 +477,8 @@ class MqttAttributesMixin(Entity):
def __init__(self, config: ConfigType) -> None: def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin.""" """Initialize the JSON attributes mixin."""
self._attributes_sub_state: dict[str, EntitySubscription] = {} self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self._attr_included_unique_ids = config[CONF_GROUP]
self._attributes_config = config self._attributes_config = config
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@@ -546,7 +550,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload) _LOGGER.warning("Erroneous JSON: %s", payload)
else: else:
if isinstance(json_dict, dict): if isinstance(json_dict, dict):
filtered_dict = { filtered_dict: dict[str, Any] = {
k: v k: v
for k, v in json_dict.items() for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
from music_assistant_models.api import ServerInfoMessage from music_assistant_models.api import ServerInfoMessage
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@@ -21,21 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
DEFAULT_URL = "http://mass.local:8095"
DEFAULT_TITLE = "Music Assistant" DEFAULT_TITLE = "Music Assistant"
DEFAULT_URL = "http://mass.local:8095"
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
"""Return a schema for the manual step."""
default_url = user_input.get(CONF_URL, DEFAULT_URL)
return vol.Schema(
{
vol.Required(CONF_URL, default=default_url): str,
}
)
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
async with MusicAssistantClient( async with MusicAssistantClient(
url, aiohttp_client.async_get_clientsession(hass) url, aiohttp_client.async_get_clientsession(hass)
@@ -52,25 +45,17 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Set up flow instance.""" """Set up flow instance."""
self.server_info: ServerInfoMessage | None = None self.url: str | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a manual configuration.""" """Handle a manual configuration."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
try: try:
self.server_info = await get_server_info( server_info = await _get_server_info(self.hass, user_input[CONF_URL])
self.hass, user_input[CONF_URL]
)
await self.async_set_unique_id(
self.server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]},
reload_on_update=True,
)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidServerVersion: except InvalidServerVersion:
@@ -79,68 +64,49 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception") LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(
server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]}
)
return self.async_create_entry( return self.async_create_entry(
title=DEFAULT_TITLE, title=DEFAULT_TITLE,
data={ data={CONF_URL: user_input[CONF_URL]},
CONF_URL: user_input[CONF_URL],
},
) )
suggested_values = user_input
if suggested_values is None:
suggested_values = {CONF_URL: DEFAULT_URL}
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=get_manual_schema(user_input), errors=errors step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_SCHEMA, suggested_values
),
errors=errors,
) )
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
async def async_step_zeroconf( async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a discovered Mass server. """Handle a zeroconf discovery for a Music Assistant server."""
This flow is triggered by the Zeroconf component. It will check if the
host is already configured and delegate to the import step if not.
"""
# abort if discovery info is not what we expect
if "server_id" not in discovery_info.properties:
return self.async_abort(reason="missing_server_id")
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
await self.async_set_unique_id(self.server_info.server_id)
# Check if we already have a config entry for this server_id
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, self.server_info.server_id
)
if existing_entry:
# If the entry was ignored or disabled, don't make any changes
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
return self.async_abort(reason="already_configured")
# Test connectivity to the current URL first
current_url = existing_entry.data[CONF_URL]
try: try:
await get_server_info(self.hass, current_url) server_info = ServerInfoMessage.from_dict(discovery_info.properties)
# Current URL is working, no need to update except LookupError:
return self.async_abort(reason="already_configured") return self.async_abort(reason="invalid_discovery_info")
except CannotConnect:
# Current URL is not working, update to the discovered URL self.url = server_info.base_url
# and continue to discovery confirm
self.hass.config_entries.async_update_entry( await self.async_set_unique_id(server_info.server_id)
existing_entry, self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
data={**existing_entry.data, CONF_URL: self.server_info.base_url},
)
# Schedule reload since URL changed
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
else:
# No existing entry, proceed with normal flow
self._abort_if_unique_id_configured()
# Test connectivity to the discovered URL
try: try:
await get_server_info(self.hass, self.server_info.base_url) await _get_server_info(self.hass, self.url)
except CannotConnect: except CannotConnect:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm() return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm( async def async_step_discovery_confirm(
@@ -148,16 +114,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle user-confirmation of discovered server.""" """Handle user-confirmation of discovered server."""
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.server_info is not None assert self.url is not None
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(
title=DEFAULT_TITLE, title=DEFAULT_TITLE,
data={ data={CONF_URL: self.url},
CONF_URL: self.server_info.base_url,
},
) )
self._set_confirm_only() self._set_confirm_only()
return self.async_show_form( return self.async_show_form(
step_id="discovery_confirm", step_id="discovery_confirm",
description_placeholders={"url": self.server_info.base_url}, description_placeholders={"url": self.url},
) )

View File

@@ -2,7 +2,7 @@
"domain": "music_assistant", "domain": "music_assistant",
"name": "Music Assistant", "name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"], "after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"], "codeowners": ["@music-assistant", "@arturpragacz"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant", "documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push", "iot_class": "local_push",

View File

@@ -57,7 +57,7 @@
"message": "Error while loading the integration." "message": "Error while loading the integration."
}, },
"implementation_unavailable": { "implementation_unavailable": {
"message": "OAuth2 implementation is not available, will retry." "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"incorrect_oauth2_scope": { "incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions." "message": "Stored permissions are invalid. Please login again to update permissions."

View File

@@ -20,10 +20,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import aiohttp_client, config_validation as cv
aiohttp_client, from homeassistant.helpers.config_entry_oauth2_flow import (
config_entry_oauth2_flow, ImplementationUnavailableError,
config_validation as cv, OAuth2Session,
async_get_config_entry_implementation,
) )
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -73,17 +74,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Netatmo from a config entry.""" """Set up Netatmo from a config entry."""
implementation = ( try:
await config_entry_oauth2_flow.async_get_config_entry_implementation( implementation = await async_get_config_entry_implementation(hass, entry)
hass, entry except ImplementationUnavailableError as err:
) raise ConfigEntryNotReady(
) translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
# Set unique id if non was set (migration) # Set unique id if non was set (migration)
if not entry.unique_id: if not entry.unique_id:
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) session = OAuth2Session(hass, entry, implementation)
try: try:
await session.async_ensure_token_valid() await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as ex: except aiohttp.ClientResponseError as ex:

View File

@@ -143,6 +143,11 @@
} }
} }
}, },
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": { "options": {
"step": { "step": {
"public_weather": { "public_weather": {

View File

@@ -12,7 +12,12 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER from .const import _LOGGER
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController] type NikoHomeControlConfigEntry = ConfigEntry[NHCController]

View File

@@ -0,0 +1,100 @@
"""Support for Niko Home Control thermostats."""
from typing import Any
from nhc.const import THERMOSTAT_MODES, THERMOSTAT_MODES_REVERSE
from nhc.thermostat import NHCThermostat
from homeassistant.components.climate import (
PRESET_ECO,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.sensor import UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NikoHomeControlConfigEntry
from .const import (
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP,
NikoHomeControlThermostatModes,
)
from .entity import NikoHomeControlEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control thermostat entry."""
controller = entry.runtime_data
async_add_entities(
NikoHomeControlClimate(thermostat, controller, entry.entry_id)
for thermostat in controller.thermostats.values()
)
class NikoHomeControlClimate(NikoHomeControlEntity, ClimateEntity):
"""Representation of a Niko Home Control thermostat."""
_attr_supported_features: ClimateEntityFeature = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_name = None
_action: NHCThermostat
_attr_translation_key = "nhc_thermostat"
_attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO]
_attr_preset_modes = [
"day",
"night",
PRESET_ECO,
"prog1",
"prog2",
"prog3",
]
def _get_niko_mode(self, mode: str) -> int:
"""Return the Niko mode."""
return THERMOSTAT_MODES_REVERSE.get(mode, NikoHomeControlThermostatModes.OFF)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs:
await self._action.set_temperature(kwargs.get(ATTR_TEMPERATURE))
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self._action.set_mode(self._get_niko_mode(preset_mode))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
await self._action.set_mode(NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP[hvac_mode])
async def async_turn_off(self) -> None:
"""Turn thermostat off."""
await self._action.set_mode(NikoHomeControlThermostatModes.OFF)
def update_state(self) -> None:
"""Update the state of the entity."""
if self._action.state == NikoHomeControlThermostatModes.OFF:
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = None
elif self._action.state == NikoHomeControlThermostatModes.COOL:
self._attr_hvac_mode = HVACMode.COOL
self._attr_preset_mode = None
else:
self._attr_hvac_mode = HVACMode.AUTO
self._attr_preset_mode = THERMOSTAT_MODES[self._action.state]
self._attr_target_temperature = self._action.setpoint
self._attr_current_temperature = self._action.measured

View File

@@ -1,6 +1,23 @@
"""Constants for niko_home_control integration.""" """Constants for niko_home_control integration."""
from enum import IntEnum
import logging import logging
from homeassistant.components.climate import HVACMode
DOMAIN = "niko_home_control" DOMAIN = "niko_home_control"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP = {
HVACMode.OFF: 3,
HVACMode.COOL: 4,
HVACMode.AUTO: 5,
}
class NikoHomeControlThermostatModes(IntEnum):
"""Enum for Niko Home Control thermostat modes."""
OFF = 3
COOL = 4
AUTO = 5

View File

@@ -0,0 +1,20 @@
{
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"default": "mdi:calendar-clock",
"state": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night",
"prog1": "mdi:numeric-1",
"prog2": "mdi:numeric-2",
"prog3": "mdi:numeric-3"
}
}
}
}
}
}
}

View File

@@ -26,5 +26,23 @@
"description": "Set up your Niko Home Control instance." "description": "Set up your Niko Home Control instance."
} }
} }
},
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"day": "Day",
"eco": "Eco",
"night": "Night",
"prog1": "Program 1",
"prog2": "Program 2",
"prog3": "Program 3"
}
}
}
}
}
} }
} }

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

View File

@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti", "documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.19"] "requirements": ["pypalazzetti==0.1.20"]
} }

View File

@@ -256,6 +256,7 @@ class PlaystationNetworkFriendDataCoordinator(
account_id=self.user.account_id, account_id=self.user.account_id,
presence=self.user.get_presence(), presence=self.user.get_presence(),
profile=self.profile, profile=self.profile,
trophy_summary=self.user.trophy_summary(),
) )
except PSNAWPForbiddenError as error: except PSNAWPForbiddenError as error:
raise UpdateFailed( raise UpdateFailed(

View File

@@ -54,7 +54,7 @@ class PlaystationNetworkSensor(StrEnum):
NOW_PLAYING = "now_playing" NOW_PLAYING = "now_playing"
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription( PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL, key=PlaystationNetworkSensor.TROPHY_LEVEL,
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL, translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
@@ -106,8 +106,6 @@ SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...
else None else None
), ),
), ),
)
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription( PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID, key=PlaystationNetworkSensor.ONLINE_ID,
translation_key=PlaystationNetworkSensor.ONLINE_ID, translation_key=PlaystationNetworkSensor.ONLINE_ID,
@@ -152,7 +150,7 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.user_data coordinator = config_entry.runtime_data.user_data
async_add_entities( async_add_entities(
PlaystationNetworkSensorEntity(coordinator, description) PlaystationNetworkSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER for description in SENSOR_DESCRIPTIONS
) )
for ( for (
@@ -166,7 +164,7 @@ async def async_setup_entry(
description, description,
config_entry.subentries[subentry_id], config_entry.subentries[subentry_id],
) )
for description in SENSOR_DESCRIPTIONS_USER for description in SENSOR_DESCRIPTIONS
], ],
config_subentry_id=subentry_id, config_subentry_id=subentry_id,
) )

View File

@@ -57,12 +57,14 @@ type SelectType = Literal[
"select_gateway_mode", "select_gateway_mode",
"select_regulation_mode", "select_regulation_mode",
"select_schedule", "select_schedule",
"select_zone_profile",
] ]
type SelectOptionsType = Literal[ type SelectOptionsType = Literal[
"available_schedules",
"dhw_modes", "dhw_modes",
"gateway_modes", "gateway_modes",
"regulation_modes", "regulation_modes",
"available_schedules", "zone_profiles",
] ]
# Default directives # Default directives
@@ -82,3 +84,10 @@ MASTER_THERMOSTATS: Final[list[str]] = [
"zone_thermometer", "zone_thermometer",
"zone_thermostat", "zone_thermostat",
] ]
# Select constants
SELECT_DHW_MODE: Final = "select_dhw_mode"
SELECT_GATEWAY_MODE: Final = "select_gateway_mode"
SELECT_REGULATION_MODE: Final = "select_regulation_mode"
SELECT_SCHEDULE: Final = "select_schedule"
SELECT_ZONE_PROFILE: Final = "select_zone_profile"

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["plugwise"], "loggers": ["plugwise"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["plugwise==1.9.0"], "requirements": ["plugwise==1.10.0"],
"zeroconf": ["_plugwise._tcp.local."] "zeroconf": ["_plugwise._tcp.local."]
} }

View File

@@ -9,7 +9,15 @@ from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SelectOptionsType, SelectType from .const import (
SELECT_DHW_MODE,
SELECT_GATEWAY_MODE,
SELECT_REGULATION_MODE,
SELECT_SCHEDULE,
SELECT_ZONE_PROFILE,
SelectOptionsType,
SelectType,
)
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity from .entity import PlugwiseEntity
from .util import plugwise_command from .util import plugwise_command
@@ -27,28 +35,34 @@ class PlugwiseSelectEntityDescription(SelectEntityDescription):
SELECT_TYPES = ( SELECT_TYPES = (
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_schedule", key=SELECT_SCHEDULE,
translation_key="select_schedule", translation_key=SELECT_SCHEDULE,
options_key="available_schedules", options_key="available_schedules",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_regulation_mode", key=SELECT_REGULATION_MODE,
translation_key="regulation_mode", translation_key=SELECT_REGULATION_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="regulation_modes", options_key="regulation_modes",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_dhw_mode", key=SELECT_DHW_MODE,
translation_key="dhw_mode", translation_key=SELECT_DHW_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="dhw_modes", options_key="dhw_modes",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_gateway_mode", key=SELECT_GATEWAY_MODE,
translation_key="gateway_mode", translation_key=SELECT_GATEWAY_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="gateway_modes", options_key="gateway_modes",
), ),
PlugwiseSelectEntityDescription(
key=SELECT_ZONE_PROFILE,
translation_key=SELECT_ZONE_PROFILE,
entity_category=EntityCategory.CONFIG,
options_key="zone_profiles",
),
) )

View File

@@ -109,7 +109,7 @@
} }
}, },
"select": { "select": {
"dhw_mode": { "select_dhw_mode": {
"name": "DHW mode", "name": "DHW mode",
"state": { "state": {
"auto": "[%key:common::state::auto%]", "auto": "[%key:common::state::auto%]",
@@ -118,7 +118,7 @@
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
}, },
"gateway_mode": { "select_gateway_mode": {
"name": "Gateway mode", "name": "Gateway mode",
"state": { "state": {
"away": "Pause", "away": "Pause",
@@ -126,7 +126,7 @@
"vacation": "Vacation" "vacation": "Vacation"
} }
}, },
"regulation_mode": { "select_regulation_mode": {
"name": "Regulation mode", "name": "Regulation mode",
"state": { "state": {
"bleeding_cold": "Bleeding cold", "bleeding_cold": "Bleeding cold",
@@ -141,6 +141,14 @@
"state": { "state": {
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
},
"select_zone_profile": {
"name": "Zone profile",
"state": {
"active": "[%key:common::state::active%]",
"off": "[%key:common::state::off%]",
"passive": "Passive"
}
} }
}, },
"sensor": { "sensor": {

View File

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

View File

@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8( schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,) instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
) )
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm): for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table) schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table) schema_errors |= validate_table_schema_has_correct_collation(instance, table)

View File

@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37 EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38 STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49 CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 51 UNIT_CLASS_SCHEMA_VERSION = 52
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration.""" """Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51 SCHEMA_VERSION = 53
_LOGGER = logging.getLogger(__name__) _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 LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16 CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci" MYSQL_COLLATE = "utf8mb4_bin"
MYSQL_DEFAULT_CHARSET = "utf8mb4" MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB" MYSQL_ENGINE = "InnoDB"

View File

@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID from uuid import UUID
import sqlalchemy import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import ( from sqlalchemy.exc import (
DatabaseError, DatabaseError,
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
@@ -1352,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21): class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
def _apply_update(self) -> None: def _apply_update(self) -> None:
"""Version specific update method.""" """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: if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in ("events", "states", "statistics_meta"): for table in ("events", "states", "statistics_meta"):
_correct_table_character_set_and_collation(table, self.session_maker) _correct_table_character_set_and_collation(table, self.session_maker)
@@ -2044,17 +2053,94 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51): class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None: def _apply_update(self) -> None:
"""Version specific update method.""" """Version specific update method."""
# Add unit class column to StatisticsMeta # Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"]) _add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session: with session_scope(session=self.session_maker()) as session:
connection = session.connection() connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS: for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute( connection.execute(
update(StatisticsMeta) update(StatisticsMeta)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS)) .where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS) .values(unit_class=conv.UNIT_CLASS)
) )
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
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( def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant, hass: HomeAssistant,
@@ -2098,8 +2184,10 @@ def _correct_table_character_set_and_collation(
"""Correct issues detected by validate_db_schema.""" """Correct issues detected by validate_db_schema."""
# Attempt to convert the table to utf8mb4 # Attempt to convert the table to utf8mb4
_LOGGER.warning( _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, table,
MYSQL_DEFAULT_CHARSET,
MYSQL_COLLATE,
MIGRATION_NOTE_MINUTES, MIGRATION_NOTE_MINUTES,
) )
with ( with (

View File

@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
QUERY_STATISTIC_META = ( QUERY_STATISTICS_META = (
StatisticsMeta.id, StatisticsMeta.id,
StatisticsMeta.statistic_id, StatisticsMeta.statistic_id,
StatisticsMeta.source, StatisticsMeta.source,
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
""" """
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META) columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type) columns.append(StatisticsMeta.mean_type)
else: else:

View File

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

View File

@@ -3,10 +3,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict
import logging import logging
from satel_integra.satel_integra import AlarmState from satel_integra.satel_integra import AlarmState, AsyncSatel
from homeassistant.components.alarm_control_panel import ( from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity, AlarmControlPanelEntity,
@@ -16,17 +15,31 @@ from homeassistant.components.alarm_control_panel import (
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ( from .const import (
CONF_ARM_HOME_MODE, CONF_ARM_HOME_MODE,
CONF_PARTITION_NUMBER, CONF_PARTITION_NUMBER,
DOMAIN,
SIGNAL_PANEL_MESSAGE, SIGNAL_PANEL_MESSAGE,
SUBENTRY_TYPE_PARTITION, SUBENTRY_TYPE_PARTITION,
SatelConfigEntry, SatelConfigEntry,
) )
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__) _LOGGER = logging.getLogger(__name__)
@@ -45,9 +58,9 @@ async def async_setup_entry(
) )
for subentry in partition_subentries: for subentry in partition_subentries:
partition_num = subentry.data[CONF_PARTITION_NUMBER] partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
zone_name = subentry.data[CONF_NAME] zone_name: str = subentry.data[CONF_NAME]
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE] arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
async_add_entities( async_add_entities(
[ [
@@ -73,20 +86,31 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_AWAY
) )
_attr_has_entity_name = True
_attr_name = None
def __init__( def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id self,
controller: AsyncSatel,
device_name: str,
arm_home_mode: int,
partition_id: int,
config_entry_id: str,
) -> None: ) -> None:
"""Initialize the alarm panel.""" """Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}" self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode self._arm_home_mode = arm_home_mode
self._partition_id = partition_id self._partition_id = partition_id
self._satel = controller self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates.""" """Update alarm status and register callbacks for future updates."""
_LOGGER.debug("Starts listening for panel messages") self._attr_alarm_state = self._read_alarm_state()
self._update_alarm_status()
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
@@ -94,55 +118,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
) )
@callback @callback
def _update_alarm_status(self): def _update_alarm_status(self) -> None:
"""Handle alarm status update.""" """Handle alarm status update."""
state = self._read_alarm_state() state = self._read_alarm_state()
_LOGGER.debug("Got status update, current status: %s", state)
if state != self._attr_alarm_state: if state != self._attr_alarm_state:
self._attr_alarm_state = state self._attr_alarm_state = state
self.async_write_ha_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.""" """Read current status of the alarm and translate it into HA status."""
# Default - disarmed:
hass_alarm_status = AlarmControlPanelState.DISARMED
if not self._satel.connected: if not self._satel.connected:
_LOGGER.debug("Alarm panel not connected")
return None return None
state_map = OrderedDict( for satel_state, ha_state in ALARM_STATE_MAP.items():
[
(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():
if ( if (
satel_state in self._satel.partition_states satel_state in self._satel.partition_states
and self._partition_id in self._satel.partition_states[satel_state] and self._partition_id in self._satel.partition_states[satel_state]
): ):
hass_alarm_status = ha_state return ha_state
break
return hass_alarm_status return AlarmControlPanelState.DISARMED
async def async_alarm_disarm(self, code: str | None = None) -> None: async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command.""" """Send disarm command."""
@@ -154,8 +152,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED 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._partition_id])
if clear_alarm_necessary: if clear_alarm_necessary:
@@ -165,14 +161,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
_LOGGER.debug("Arming away")
if code: if code:
await self._satel.arm(code, [self._partition_id]) await self._satel.arm(code, [self._partition_id])
async def async_alarm_arm_home(self, code: str | None = None) -> None: async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command.""" """Send arm home command."""
_LOGGER.debug("Arming home")
if code: if code:
await self._satel.arm(code, [self._partition_id], self._arm_home_mode) await self._satel.arm(code, [self._partition_id], self._arm_home_mode)

View File

@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -17,6 +20,7 @@ from .const import (
CONF_ZONE_NUMBER, CONF_ZONE_NUMBER,
CONF_ZONE_TYPE, CONF_ZONE_TYPE,
CONF_ZONES, CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED, SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED, SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT, SUBENTRY_TYPE_OUTPUT,
@@ -40,9 +44,9 @@ async def async_setup_entry(
) )
for subentry in zone_subentries: for subentry in zone_subentries:
zone_num = subentry.data[CONF_ZONE_NUMBER] zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type = subentry.data[CONF_ZONE_TYPE] zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name = subentry.data[CONF_NAME] zone_name: str = subentry.data[CONF_NAME]
async_add_entities( async_add_entities(
[ [
@@ -65,9 +69,9 @@ async def async_setup_entry(
) )
for subentry in output_subentries: for subentry in output_subentries:
output_num = subentry.data[CONF_OUTPUT_NUMBER] output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type = subentry.data[CONF_ZONE_TYPE] ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name = subentry.data[CONF_NAME] output_name: str = subentry.data[CONF_NAME]
async_add_entities( async_add_entities(
[ [
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
"""Representation of an Satel Integra binary sensor.""" """Representation of an Satel Integra binary sensor."""
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__( def __init__(
self, self,
controller, controller: AsyncSatel,
device_number, device_number: int,
device_name, device_name: str,
zone_type, device_class: BinarySensorDeviceClass,
sensor_type, sensor_type: str,
react_to_signal, react_to_signal: str,
config_entry_id, config_entry_id: str,
): ) -> None:
"""Initialize the binary_sensor.""" """Initialize the binary_sensor."""
self._device_number = device_number self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}" self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._name = device_name
self._zone_type = zone_type
self._state = 0
self._react_to_signal = react_to_signal self._react_to_signal = react_to_signal
self._satel = controller self._satel = controller
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED: if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
if self._device_number in self._satel.violated_outputs: self._attr_is_on = self._device_number in self._satel.violated_outputs
self._state = 1
else: else:
self._state = 0 self._attr_is_on = self._device_number in self._satel.violated_zones
elif self._device_number in self._satel.violated_zones:
self._state = 1
else:
self._state = 0
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self._react_to_signal, self._devices_updated self.hass, self._react_to_signal, self._devices_updated
) )
) )
@property
def name(self):
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str | None:
"""Icon for device by its type."""
if self._zone_type is BinarySensorDeviceClass.SMOKE:
return "mdi:fire"
return None
@property
def is_on(self):
"""Return true if sensor is on."""
return self._state == 1
@property
def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@callback @callback
def _devices_updated(self, zones): def _devices_updated(self, zones: dict[int, int]):
"""Update the zone's state, if needed.""" """Update the zone's state, if needed."""
if self._device_number in zones and self._state != zones[self._device_number]: if self._device_number in zones:
self._state = zones[self._device_number] new_state = zones[self._device_number] == 1
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state() self.async_write_ha_state()

View File

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

View File

@@ -12,10 +12,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import config_validation as cv, httpx_client
config_entry_oauth2_flow, from homeassistant.helpers.config_entry_oauth2_flow import (
config_validation as cv, ImplementationUnavailableError,
httpx_client, OAuth2Session,
async_get_config_entry_implementation,
) )
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -28,19 +29,22 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] 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.""" """Set up SENZ from a config entry."""
implementation = ( try:
await config_entry_oauth2_flow.async_get_config_entry_implementation( implementation = await async_get_config_entry_implementation(hass, entry)
hass, entry except ImplementationUnavailableError as err:
) raise ConfigEntryNotReady(
) translation_domain=DOMAIN,
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session) auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
senz_api = SENZAPI(auth) senz_api = SENZAPI(auth)
@@ -68,16 +72,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh() 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True 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.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -12,30 +12,29 @@ from homeassistant.components.climate import (
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the SENZ climate entities from a config entry.""" """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( async_add_entities(
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values() SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
) )
class SENZClimate(CoordinatorEntity, ClimateEntity): class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
"""Representation of a SENZ climate entity.""" """Representation of a SENZ climate entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS

View File

@@ -0,0 +1,26 @@
"""Diagnostics platform for Senz integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import SENZConfigEntry
TO_REDACT = [
"access_token",
"refresh_token",
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: SENZConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"thermostats": raw_data,
}

View File

@@ -0,0 +1,92 @@
"""nVent RAYCHEM SENZ sensor platform."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aiosenz import Thermostat
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
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 SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
@dataclass(kw_only=True, frozen=True)
class SenzSensorDescription(SensorEntityDescription):
"""Describes SENZ sensor entity."""
value_fn: Callable[[Thermostat], str | int | float | None]
SENSORS: tuple[SenzSensorDescription, ...] = (
SenzSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda data: data.current_temperatue,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS
for thermostat in coordinator.data.values()
)
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
"""Representation of a SENZ sensor entity."""
entity_description: SenzSensorDescription
_attr_has_entity_name = True
def __init__(
self,
thermostat: Thermostat,
coordinator: SENZDataUpdateCoordinator,
description: SenzSensorDescription,
) -> None:
"""Init SENZ sensor."""
super().__init__(coordinator)
self.entity_description = description
self._thermostat = thermostat
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, thermostat.serial_number)},
manufacturer="nVent Raychem",
model="SENZ WIFI",
name=thermostat.name,
serial_number=thermostat.serial_number,
)
@property
def available(self) -> bool:
"""Return True if the thermostat is available."""
return super().available and self._thermostat.online
@property
def native_value(self) -> str | float | int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._thermostat)

View File

@@ -25,5 +25,10 @@
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
} }
} }
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
} }
} }

View File

@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import ( from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM, DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
NumberExtraStoredData, NumberExtraStoredData,
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
if description.mode_fn is not None: if description.mode_fn is not None:
self._attr_mode = description.mode_fn(coordinator.device.config[key]) self._attr_mode = description.mode_fn(coordinator.device.config[key])
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property @property
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return value of number.""" """Return value of number."""
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription( ("device", "valvePos"): BlockNumberDescription(
key="device|valvepos", key="device|valvepos",
translation_key="valve_position", translation_key="valve_position",
name="Valve position",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
available=lambda block: cast(int, block.valveError) != 1, available=lambda block: cast(int, block.valveError) != 1,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
key="blutrv", key="blutrv",
sub_key="current_C", sub_key="current_C",
translation_key="external_temperature", translation_key="external_temperature",
name="External temperature",
native_min_value=-50, native_min_value=-50,
native_max_value=50, native_max_value=50,
native_step=0.1, native_step=0.1,
mode=NumberMode.BOX, mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
method="blu_trv_set_external_temperature", method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber, entity_class=RpcBluTrvExtTempNumber,
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription( "number_generic": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform( removal_condition=lambda config, _, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM config, key, NUMBER_PLATFORM
), ),
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
"number_current_limit": RpcNumberDescription( "number_current_limit": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="current_limit",
device_class=NumberDeviceClass.CURRENT,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
"number_position": RpcNumberDescription( "number_position": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="valve_position",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
"number_target_humidity": RpcNumberDescription( "number_target_humidity": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="target_humidity",
device_class=NumberDeviceClass.HUMIDITY,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
"number_target_temperature": RpcNumberDescription( "number_target_temperature": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
key="blutrv", key="blutrv",
sub_key="pos", sub_key="pos",
translation_key="valve_position", translation_key="valve_position",
name="Valve position",
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
native_step=1, native_step=1,
mode=NumberMode.SLIDER, mode=NumberMode.SLIDER,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
method="blu_trv_set_valve_position", method="blu_trv_set_valve_position",
removal_condition=lambda config, _status, key: config[key].get("enable", True) removal_condition=lambda config, _, key: config[key].get("enable", True)
is True, is True,
entity_class=RpcBluTrvNumber, entity_class=RpcBluTrvNumber,
), ),
"left_slot_intensity": RpcNumberDescription( "left_slot_intensity": RpcNumberDescription(
key="cury", key="cury",
sub_key="slots", sub_key="slots",
name="Left slot intensity", translation_key="left_slot_intensity",
value=lambda status, _: status["left"]["intensity"], value=lambda status, _: status["left"]["intensity"],
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription( "right_slot_intensity": RpcNumberDescription(
key="cury", key="cury",
sub_key="slots", sub_key="slots",
name="Right slot intensity", translation_key="right_slot_intensity",
value=lambda status, _: status["right"]["intensity"], value=lambda status, _: status["right"]["intensity"],
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
self.restored_data: NumberExtraStoredData | None = None self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry) super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle entity which will be added.""" """Handle entity which will be added."""
await super().async_added_to_hass() await super().async_added_to_hass()

View File

@@ -188,6 +188,29 @@
} }
} }
}, },
"number": {
"current_limit": {
"name": "Current limit"
},
"external_temperature": {
"name": "External temperature"
},
"left_slot_intensity": {
"name": "Left slot intensity"
},
"right_slot_intensity": {
"name": "Right slot intensity"
},
"target_humidity": {
"name": "Target humidity"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
}
},
"select": { "select": {
"cury_mode": { "cury_mode": {
"name": "Mode", "name": "Mode",

View File

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

View File

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

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