Compare commits

...

493 Commits

Author SHA1 Message Date
Kevin Stillhammer
afc256622a raise proper service exceptions in fressnapf_tracker (#159707)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-01-02 19:53:16 +01:00
Erwin Douna
bfef048a7c Bump pyportainer 1.0.22 (#160140) 2026-01-02 18:37:14 +01:00
Maikel Punie
bfc8111728 Bump velbus to silver integration scale (#160147) 2026-01-02 18:36:04 +01:00
Maikel Punie
ebd6ae7e80 Velbus mark entities unavailable when connection is terminated (#160143) 2026-01-02 17:43:33 +01:00
MarkGodwin
dd98a85300 Refactor TP-Link Omada config flow tests (#159950)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-02 17:41:04 +01:00
Brett Adams
6568a19ce6 Handle export options when enrolled to VPP in Teslemetry (#157665) 2026-01-02 16:52:03 +01:00
wollew
83c1e8d5b5 bump pyvlx version to 0.2.27 (#160139) 2026-01-02 16:49:09 +01:00
Simone Chemelli
c5a06657a3 Remove low level call for Shelly climate (#160065) 2026-01-02 16:47:39 +01:00
Maciej Bieniek
25e54990d2 Bump nextdns to version 5.0.0 (#160138) 2026-01-02 16:33:27 +01:00
Nikoheld
3b2a7ba561 bump nibe to 2.21.0 (#160135) 2026-01-02 16:06:43 +01:00
Åke Strandberg
8f8f896675 Add filling level sensors to miele (#157858) 2026-01-02 15:57:15 +01:00
Willem-Jan van Rootselaar
9539a612a6 Add time synchronization feature to BSB-Lan integration (#156600) 2026-01-02 15:54:37 +01:00
Erwin Douna
d6751eb63f Bump pyportainer 1.0.21 (#160130) 2026-01-02 15:06:46 +01:00
Pete Sage
b462038126 Use long service timeout for Sonos Unjoin (#160110) 2026-01-02 14:18:56 +01:00
cdnninja
ce06446376 Add pm1 and pm10 to vesync (#160072)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-02 14:15:52 +01:00
Erik Montnemery
8de22e0134 Await writes in shopping_list action handlers (#157420) 2026-01-02 13:41:41 +01:00
mettolen
fbd08d4e42 Bump pyairobotrest to 0.2.0 (#160125) 2026-01-02 12:29:29 +01:00
Zoltán Farkasdi
32e0be4535 netatmo: test_camera webhook testing parametrize and light split (#159772) 2026-01-02 11:00:17 +01:00
Maikel Punie
0423639833 Bump velbusaio to 2026.1.1 (#160116) 2026-01-02 09:16:27 +01:00
Jan Bouwhuis
1244d8aa33 Fix reolink brightness scaling (#160106) 2026-01-01 21:56:35 +01:00
Pete Sage
38c37ab33c Improve Sonos wait to unjoin timeout (#160011) 2026-01-01 20:21:25 +01:00
Willem-Jan van Rootselaar
1636eab2e8 Add schema validation for set_hot_water_schedule service (#159990) 2026-01-01 20:16:54 +01:00
Miguel Camba
737a5811a9 Update voluptuous and voluptuous-openapi (#160073) 2026-01-01 20:07:06 +01:00
Austin Mroczek
5f2da20319 Bump total_connect_client to 2025.12.2 (#160075) 2026-01-01 20:02:56 +01:00
Michael Hansen
2aed4fb8e9 Bump intents to 2026.1.1 (#160099) 2026-01-01 19:58:37 +01:00
Lukas
2b10dc4545 Add reconfiguration flow to pooldose (#159978)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-01 17:20:33 +01:00
Maikel Punie
b5d22a63bb Velbus quality docs updates (#160092) 2026-01-01 17:02:30 +01:00
Maikel Punie
e8e19f47cd Velbus Exception translations (#159627)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-01 16:51:39 +01:00
Maikel Punie
97e6643cd7 Bump velbusaio to 2026.1.0 (#160087) 2026-01-01 16:50:28 +01:00
Ben Wolstencroft
ee4bb0eef5 Add support for health_overview API endpoint to Tractive integration (#157960)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-01 13:06:24 +01:00
Maikel Punie
f82bb8f0b8 Use brightness scale in velbus light (#160041) 2026-01-01 13:03:52 +01:00
cdnninja
79b368cfc3 add description to string vesync (#160003) 2025-12-31 22:20:50 +01:00
cdnninja
6da4a006f2 Add Auto Off Switch to VeSync (#160070) 2025-12-31 22:17:33 +01:00
Allen Porter
e5f3ccb38d Improve roborock test accuracy/robustness (#160021) 2025-12-31 16:32:53 +01:00
tronikos
560b91b93b Filter out duplicate voices without language code in Google Cloud (#160046) 2025-12-31 16:30:53 +01:00
Pete Sage
edd9f50562 bump soco to 0.30.14 for Sonos (#160050) 2025-12-31 16:25:55 +01:00
Paul Tarjan
a4b2e84b03 Fix Hikvision thread safety issue when calling async_write_ha_state (#160027) 2025-12-31 15:52:41 +01:00
rlippmann
9da07c2058 remove domain and service slots from Service object (#160039) 2025-12-31 13:34:02 +01:00
Simone Chemelli
8de6785182 Bump aioamazondevices to 11.0.2 (#160016)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-31 12:31:32 +01:00
Anders Melchiorsen
77f6fa8116 Bump eternalegypt to 0.0.18 (#160006) 2025-12-31 10:57:58 +01:00
Anders Melchiorsen
6b6f338e7e Fix netgear_lte unloading (#160008) 2025-12-31 10:53:24 +01:00
David Knowles
aa995fb590 Use WATER device_class for Hydrawise sensors (#160018) 2025-12-31 10:47:48 +01:00
Anders Melchiorsen
f0fee87b9e Move async_setup_services to async_setup for netgear_lte (#160007) 2025-12-31 10:43:59 +01:00
Erwin Douna
56ab3bf59b Bump pyfirefly 0.1.10 (#160028) 2025-12-31 09:04:40 +01:00
Luke Lashley
24e2720924 Don't prefer cache for Roborock device fetching (#160022) 2025-12-30 13:21:54 -08:00
Erwin Douna
bacc2f00af Bump portainer 1.0.19 (#160014) 2025-12-30 21:13:24 +01:00
Manu
6de2d6810b Convert store image URLs to https in Xbox media resolver (#160015) 2025-12-30 21:10:51 +01:00
Allen Porter
de07833d92 Update roborock binary sensor tests with snapshots (#159981) 2025-12-30 19:36:32 +01:00
Matthias Alphart
b4eff231c3 Update knx-frontend to 2025.12.30.151231 (#159999) 2025-12-30 18:49:02 +01:00
Luke Lashley
98fea46eea Add support for vacuum entity for Roborock Q7 (#159966) 2025-12-30 07:26:18 -08:00
divers33
18e8821891 Add podcast favorites support to Sonos media browser (#159961)
Co-authored-by: divers33 <divers33@users.noreply.github.com>
2025-12-30 15:14:53 +01:00
Sab44
cc2377d44d Bump librehardwaremonitor-api to version 1.7.2 (#159987) 2025-12-30 12:18:50 +01:00
doomsniper09
8370c6abfb Accept integer coordinates in has_location helper (#159835)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-12-30 12:06:23 +01:00
Panda-NZ
2d1a672de5 Add ambient temperature sensor to ToGrill (#159798) 2025-12-30 09:44:23 +01:00
Ernst Klamer
75ea42a834 bump xiaomi-ble to 1.4.1 (#159954) 2025-12-30 00:12:45 +01:00
Lukas
45491e17cd Pooldose Diagnostics (#159965) 2025-12-29 23:03:13 +01:00
Stefan H.
b994f03391 Migrate traccar_server to use entry.runtime_data (#156065)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 22:16:01 +01:00
Kamil Breguła
473cb59013 Add translation of exceptions in met (#155765)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 22:12:40 +01:00
J. Nick Koston
9302926d99 Bump aioesphomeapi to 43.9.1 (#159960) 2025-12-29 11:09:37 -10:00
Branden Cash
d92516b7c9 Implement reconfigure config flow in SRP energy (#151542)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:52:25 +01:00
Luke Lashley
5b561213d3 Bump Python-Roborock to 4.1.0 (#159963) 2025-12-29 21:52:13 +01:00
Erwin Douna
0a16bd4919 Portainer fix stopped container for stats (#159964) 2025-12-29 21:51:46 +01:00
Michael
f74a6e2625 Record current Feedreader integration quality scale and set to silver (#143179)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:36:23 +01:00
Joost Lekkerkerker
ecc271409a Small cleanup in Feedreader (#159962) 2025-12-29 21:31:25 +01:00
Michael
1f63bc3231 Record current Synology DSM integration quality scale (#141245)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:24:18 +01:00
Joost Lekkerkerker
78adeb837e Inject session in Switchbot cloud (#159942) 2025-12-29 21:18:34 +01:00
Joost Lekkerkerker
bfacf462bf Add integration_type service to nuheat (#159845) 2025-12-29 21:12:23 +01:00
Joost Lekkerkerker
771d40dbf6 Add integration_type hub to permobil (#159872) 2025-12-29 21:12:05 +01:00
Joost Lekkerkerker
8e441242ad Add integration_type hub to pooldose (#159880) 2025-12-29 21:11:46 +01:00
Joost Lekkerkerker
b8a4237ab1 Add integration_type hub to poolsense (#159881) 2025-12-29 21:11:17 +01:00
Joost Lekkerkerker
e92af1ee76 Add integration_type device to ps4 (#159892) 2025-12-29 21:10:52 +01:00
Matthias Alphart
e561c1cebb Fix KNX translation references (#159959) 2025-12-29 20:50:53 +01:00
Franck Nijhof
d77f82f8e8 Bump version to 2026.2.0dev0 (#159956) 2025-12-29 20:38:24 +01:00
Joost Lekkerkerker
fcc3598d7f Add integration_type device to netgear (#159816) 2025-12-29 21:14:58 +02:00
Joost Lekkerkerker
a1a1d65ee4 Add Hood fan speed select entity to SmartThings (#157841)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 19:56:55 +01:00
Louis Christ
8778d4c704 Move actions to async_setup in bluesound (#159809) 2025-12-29 19:44:05 +01:00
Jeremiah Paige
7790a2ebdd Add config flow to wsdot (#149208)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 18:58:09 +01:00
Felipe Santos
585c2dce16 Add OpenRGB profile select entity (#154732) 2025-12-29 17:42:20 +01:00
Tom Matheussen
08d25d388f Address Satel Integra config flow test comments (#159951) 2025-12-29 17:37:01 +01:00
MarkGodwin
f06f25b99a Delay creation of some Omada device entities when devices are not connected (#156665) 2025-12-29 17:23:42 +01:00
Joost Lekkerkerker
f11791f84d Fix CI by freezing time in Growatt tests (#159946) 2025-12-29 17:18:18 +01:00
Eduardo Tsen
8a3c0edb59 Publish area and floor metrics to Prometheus (#159322) 2025-12-29 17:08:55 +01:00
ElCruncharino
e7176c4919 Fix Backblaze B2 timeout issues during backup uploads (#158272) 2025-12-29 17:02:19 +01:00
Willem-Jan van Rootselaar
3327c3513b Add service for setting hot water schedule (#156112)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 17:00:50 +01:00
Simone Chemelli
8ca87ef1cb Add support for Comelit Vedo system connected via Comelit Serial bridge (#156301) 2025-12-29 16:59:52 +01:00
Franck Nijhof
d90e72c6d4 Update frontend to 20251229.0 (#159945) 2025-12-29 16:59:21 +01:00
Kurt Chrisford
4083bd3c94 Refactor Actron Air climate and switch entities to inherit from a new base entity class (#159540)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 16:57:46 +01:00
wollew
fd2a92ffce report unavailable for non-polled velux entities (#159523) 2025-12-29 16:42:17 +01:00
Manu
ac2941569e Move actions to module and improve test coverage in Duck DNS (#158079)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 16:41:06 +01:00
Brett Adams
043465e42f Replace access token authentication with OAuth2 in Teslemetry (#158905)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 16:40:45 +01:00
MarkGodwin
ec5657753f Move TP-Link Omada update coordinator into coordinator module (#159943) 2025-12-29 16:35:55 +01:00
noambav
e2fa95694f Add fish_audio integration (#152000)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 16:34:02 +01:00
Niracler
2e28796ab0 Upgrade sunricher_dali integration to silver quality scale (#159576) 2025-12-29 16:27:10 +01:00
Kamil Breguła
bdb456e568 Improve tests in WLED (#157799)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-12-29 16:26:49 +01:00
Maciej Bieniek
e1599dc53a Bump aiotractive to version 0.7.0 (#159939) 2025-12-29 16:24:26 +01:00
jesperraemaekers
0b10c36521 Bump Weheat to 2025.12.24 (#159676) 2025-12-29 16:11:28 +01:00
Tomer
f8dd05efde Minor Azure Data Explorer integration fixes (#159677) 2025-12-29 16:06:40 +01:00
Colin Finck
9e0b4c2beb kostal_plenticore: Add DcCheck state (#159679) 2025-12-29 16:06:22 +01:00
Samuel Xiao
315c7db527 Switchbot Cloud: Fixed abnormally high power consumptio (#157156)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 16:06:10 +01:00
Samuel Xiao
f3832442be Switchbot Cloud: Bumb switchbot api to v2.9.0 (#159672)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 15:59:46 +01:00
Matrix
97d1c18f21 Add support for YS7914 (#159586) 2025-12-29 15:54:33 +01:00
johanzander
307aea90d6 Increase Growatt Server test coverage to 97% (#159544)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-29 15:54:14 +01:00
Tom Matheussen
7b8d65b91f Fix Satel Options flow failing (#159736) 2025-12-29 15:52:48 +01:00
DeerMaximum
24f253f775 Add missing default values in NINA config flow (#159708) 2025-12-29 15:52:25 +01:00
dependabot[bot]
f2a4d55439 Bump dawidd6/action-download-artifact from 11 to 12 (#159768)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 15:23:53 +01:00
Andrew Jackson
33975f7c7f Add labels to Transmission add_torrent service and events (#159781) 2025-12-29 15:22:00 +01:00
cdnninja
8c9a6ccd6d Add quality scale file to vesync integration (#156663)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 15:10:30 +01:00
Bouwe Westerdijk
6fc3e2dc53 Implement shorter default update_interval for Plugwise P1 (#159626) 2025-12-29 14:42:25 +01:00
J. Diego Rodríguez Royo
28c14f21fa Add new Home Connect washing machine programs (#157174) 2025-12-29 14:42:02 +01:00
Arie Catsman
ca912699e3 Fix: Add state_class to enphase_envoy battery entities (#158103) 2025-12-29 14:41:35 +01:00
cdnninja
96d1e3d260 Use runtime_data in VeSync (#159720)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 14:40:35 +01:00
Matthias Alphart
0ea38335d7 Support KNX text entity configuration from UI (#159509) 2025-12-29 14:22:39 +01:00
Franck Nijhof
86be5d9dc3 Merge branch 'master' into dev 2025-12-29 13:21:33 +00:00
MarkGodwin
01d4c42138 Code quality fixes for TP-Link Omada service actions (#159868) 2025-12-29 14:03:43 +01:00
Jordan Harvey
a8114b7e4f Add time extended sensor for Nintendo Switch parental controls (#159227) 2025-12-29 14:00:37 +01:00
epenet
be7b7f3d25 Revert "Disable blackbird integration (#157817)" (#159369) 2025-12-29 13:59:26 +01:00
Grégoire Seux
f9481b6e51 Allow reconfigure open_router subentries (#159503)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 13:55:05 +01:00
surfingbytes
183bc31125 Add Cookidoo planned meals calendar (#159456)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 13:54:36 +01:00
Franck Nijhof
46befc257a 2025.12.5 (#159934) 2025-12-29 13:53:33 +01:00
puddly
097d190750 Replace pyserial-asyncio with serialx for ZHA and Hardware integrations (#159375)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-12-29 13:06:38 +01:00
Josef Zweck
5df03851df CI fix: Exempt caio from license check (#159866) 2025-12-29 11:58:03 +00:00
Franck Nijhof
de97a949ac Bump version to 2025.12.5 2025-12-29 11:47:21 +00:00
Víctor Gurbani
e9d2f6add2 Add state_class to Nuki battery sensor (#159756) 2025-12-29 11:44:37 +00:00
Allen Porter
5aa0eefd5f Start reauth when roborock notices the MQTT session is unauthorized (#159719) 2025-12-29 11:44:35 +00:00
Allen Porter
7d17f0a00c Fix Roborock repair issue behavior (#159718) 2025-12-29 11:44:34 +00:00
Allen Porter
e329eab514 Bump python-roborock to 3.21.1 (#159660) 2025-12-29 11:44:32 +00:00
Allen Porter
3b0ebcaa9e Bump python-roborock to 3.20.1 (#159621) 2025-12-29 11:44:01 +00:00
Robert Svensson
9595cf30bb Bump axis to v66 fixing an issue with latest xmltodict (#159604)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-29 11:41:34 +00:00
Mary
efee51548f Fix Ecoforest unknown alarm translation key (#159594) 2025-12-29 11:41:32 +00:00
Raphael Hehl
6db227e4ab Bump uiprotect to 7.33.3 (#159593) 2025-12-29 11:41:00 +00:00
Maikel Punie
4c6074621f Bump valbusaio to 2025.12.0 (#159578) 2025-12-29 11:39:52 +00:00
Magnus
584687f7c4 Bump melissa to 3.0.3 (#159557) 2025-12-29 11:39:51 +00:00
Allen Porter
e16335f15b Redact additional unnecessary diagnostic fields (#159546) 2025-12-29 11:39:50 +00:00
Raphael Hehl
960049e7d3 Improve date handling in UniFi Protect media source (#159491)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-29 11:37:22 +00:00
J. Nick Koston
0f61b68324 Bump yalexs-ble to 3.2.4 (#159476) 2025-12-29 11:37:21 +00:00
J. Nick Koston
fc5c31b348 Bump yalexs-ble to 3.2.2 (#158124) 2025-12-29 11:37:20 +00:00
Tom Harris
cba33133cd Bump insteon panel to 0.6.0 to fix dialog button issues (#159449) 2025-12-29 11:31:46 +00:00
Lukas
9631528c87 Pooldose action exceptions (#159572) 2025-12-29 12:28:54 +01:00
Pete Sage
d8b2d026c3 Create issue for Sonos when Sonos system does not have UPnP enabled (#159330) 2025-12-29 11:28:25 +00:00
Åke Strandberg
35ba9c7007 Add openid scope and update OAuth2 url:s in senz integration (#159265) 2025-12-29 11:28:23 +00:00
Rene Nulsch
cdb7b9cc25 Fix ZeroDivisionError for inverse unit conversions (#159161) 2025-12-29 11:28:22 +00:00
Paul Tarjan
ea9ac1dd36 Change Samsung TV WoL turn_on log from warning to debug (#158676) 2025-12-29 11:28:20 +00:00
Kamil Breguła
8508d48d79 Normalize unique ID in WLED (#157901)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-12-29 11:28:18 +00:00
dontinelli
2516e80663 Disable quoted cookies for compatibility with older SolarLog devices (#157839) 2025-12-29 11:28:16 +00:00
Artur Pragacz
70096d435a Remove stdlib-list requirement in hassfest docker (#159915) 2025-12-29 12:01:39 +01:00
Erwin Douna
7cfd58dce2 Bump pyportainer 1.0.17 (#159931) 2025-12-29 11:58:42 +01:00
mettolen
2fdfcd6bad Add reconfigure flow to Airobot integration (#159810) 2025-12-29 11:13:35 +01:00
Artur Pragacz
337789cd8c Fix entity id format in smhi (#159662) 2025-12-29 11:02:29 +01:00
Åke Strandberg
d87528e068 Add openid scope and update OAuth2 url:s in senz integration (#159265) 2025-12-29 10:53:41 +01:00
Artur Pragacz
dc119d47c5 Simplify entity components requirements in hassfest docker (#159914) 2025-12-29 10:50:20 +01:00
Josef Zweck
8aa897b090 Change integration_type of pure_energie to device (#159928) 2025-12-29 10:49:13 +01:00
Jan Bouwhuis
7931cb4773 Add production power sensor that is compatible with the energy power dashboard for supported homewizard devices (#159500) 2025-12-29 10:46:07 +01:00
Daniel Hjelseth Høyer
559d42dc27 Bump Adax-local to 0.3.0 (#159887)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-29 09:59:57 +01:00
J. Nick Koston
cfe6cf2448 Bump aioesphomeapi to 43.9.0 (#159924) 2025-12-29 09:58:42 +01:00
Mick Vleeshouwer
85ef06f26c Bump pyOverkiz to 1.19.3 (#159917) 2025-12-29 08:08:20 +01:00
David Recordon
25fc41a934 Explicitly pass config_entry in Control4 integration (#159920) 2025-12-29 08:07:33 +01:00
TheJulianJES
12047e8499 Bump ZHA to 0.0.82 (#159922) 2025-12-29 06:06:28 +01:00
cdnninja
702fd78d86 Fix missing vesync fan string, map modes (#158956) 2025-12-29 00:01:22 +01:00
Amolith
375b0186db Bump voluptuous-openapi to 0.2.0 (#159825)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-12-28 23:38:03 +01:00
Matthias Alphart
efd6b686a8 Update knx-frontend to 2025.12.28.215221 (#159909) 2025-12-28 23:24:36 +01:00
Kory Prince
e68ef21522 ollama integration: Don't drop all falsey values (#159735) 2025-12-28 23:22:34 +01:00
Armin Ghofrani
4f589b144d Fix ElevenLabs STT auto-detect language (#159804) 2025-12-28 23:18:34 +01:00
Martin Böh
baa4685df1 Fix Thread dataset update logic when only timestamp ticks change (#159769) 2025-12-28 23:14:32 +01:00
J. Diego Rodríguez Royo
0a6d433594 Bump aiohomeconnect to version 0.26.0 (#159801)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-12-28 23:12:08 +01:00
Michael
ed72e0d4a7 Update list of supported languages in Microsoft TTS (#159830) 2025-12-28 23:08:52 +01:00
Erwin Douna
bcdcc1208e Bump pyportainer 1.0.16 (#159904) 2025-12-28 20:20:45 +01:00
Josef Zweck
dd53a82fd5 Fix CI: Test triggers test flakyness (#159885) 2025-12-28 19:23:08 +01:00
Josef Zweck
b61c6d1edd CI fix: Exempt caio from license check (#159866) 2025-12-28 18:18:29 +01:00
Joost Lekkerkerker
ceeec6817e Add integration_type device to prusalink (#159891) 2025-12-28 18:16:15 +01:00
Joost Lekkerkerker
74370bf3ba Add integration_type device to qingping (#159899) 2025-12-28 18:13:24 +01:00
Joost Lekkerkerker
07ef6110ca Add integration_type service to pushover (#159897) 2025-12-28 18:11:31 +01:00
Joost Lekkerkerker
9e68800564 Add integration_type service to pvpc_hourly_pricing (#159898) 2025-12-28 18:11:13 +01:00
Joost Lekkerkerker
c8af5bb452 Add integration_type service to pushbullet (#159896) 2025-12-28 18:03:52 +01:00
Joost Lekkerkerker
58069fd473 Add integration_type service to purpleair (#159895) 2025-12-28 18:03:32 +01:00
Joost Lekkerkerker
822227f740 Add integration_type service to pure_energie (#159894) 2025-12-28 18:02:09 +01:00
Joost Lekkerkerker
36eece93ee Add integration_type service to pterodactyl (#159893) 2025-12-28 18:01:45 +01:00
Joost Lekkerkerker
31631bb619 Add integration_type hub to prosegur (#159889) 2025-12-28 16:57:55 +01:00
Joost Lekkerkerker
82e6b52129 Add integration_type device to progettihwsw (#159886) 2025-12-28 16:52:04 +01:00
Joost Lekkerkerker
9298b7787f Add integration_type device to private_ble_device (#159884) 2025-12-28 16:50:35 +01:00
Joost Lekkerkerker
f883eeebf3 Add integration_type device to powerwall (#159883) 2025-12-28 16:49:50 +01:00
Joost Lekkerkerker
93fe23081d Add integration_type hub to powerfox (#159882) 2025-12-28 16:48:17 +01:00
Joost Lekkerkerker
ca064bf09b Add integration_type hub to point (#159879) 2025-12-28 16:45:27 +01:00
Joost Lekkerkerker
0addd82bf7 Add integration_type service to plex (#159878) 2025-12-28 16:44:29 +01:00
Joost Lekkerkerker
4686968275 Add integration_type hub to plaato (#159877) 2025-12-28 16:44:18 +01:00
Joost Lekkerkerker
7f28f09616 Add integration_type device to p1_monitor (#159869) 2025-12-28 16:42:47 +01:00
Joost Lekkerkerker
1e9af4fbe0 Add integration_type device to panasonic_viera (#159870) 2025-12-28 16:41:56 +01:00
Joost Lekkerkerker
5399655134 Add integration_type service to peco (#159871) 2025-12-28 16:41:25 +01:00
Joost Lekkerkerker
cfaba23412 Add integration_type hub to pglab (#159873) 2025-12-28 16:37:40 +01:00
Joost Lekkerkerker
c7fa557148 Add integration_type device to pi_hole (#159875) 2025-12-28 16:35:23 +01:00
Joost Lekkerkerker
2b6abb372c Add integration_type service to picnic (#159876) 2025-12-28 16:34:45 +01:00
Joost Lekkerkerker
1ea8023753 Add integration_type device to philips_js (#159874) 2025-12-28 16:12:59 +01:00
Pete Sage
14e79ff311 Add translation string for Sonos unjoin timeout error (#159834) 2025-12-28 15:37:54 +01:00
Joost Lekkerkerker
b57e848d5d Add integration_type device to opentherm_gw (#159860) 2025-12-28 15:36:53 +01:00
Joost Lekkerkerker
938d6b6b0d Add integration_type hub to osoenergy (#159863) 2025-12-28 15:35:00 +01:00
Joost Lekkerkerker
31de4a4fa2 Add integration_type service to owntracks (#159865) 2025-12-28 15:31:25 +01:00
Joost Lekkerkerker
88b5b37f07 Add integration_type service to opower (#159862) 2025-12-28 15:30:38 +01:00
Joost Lekkerkerker
17ddba98c1 Add integration_type service to ourgroceries (#159864) 2025-12-28 15:29:59 +01:00
Joost Lekkerkerker
71fd1d079b Add integration_type service to openweathermap (#159861) 2025-12-28 15:26:26 +01:00
Joost Lekkerkerker
08a8836d29 Add integration_type device to nibe_heatpump (#159839) 2025-12-28 15:19:04 +01:00
Joost Lekkerkerker
c5261c5bb5 Add integration_type device to netgear_lte (#159817) 2025-12-28 15:16:49 +01:00
Maciej Bieniek
a82d00475c Bump accuweather to version 5.0.0 (#159831) 2025-12-28 15:15:01 +01:00
Maciej Bieniek
d62251f0a3 Bump gios to version 7.0.0 (#159832) 2025-12-28 15:14:24 +01:00
Joost Lekkerkerker
dc88502894 Add integration_type device to obihai (#159851) 2025-12-28 15:13:26 +01:00
Joost Lekkerkerker
a8a8017d35 Add integration_type service to nina (#159842) 2025-12-28 15:12:44 +01:00
Joost Lekkerkerker
de224b8107 Add integration_type service to nzbget (#159850) 2025-12-28 15:12:32 +01:00
Joost Lekkerkerker
841baa15b6 Add integration_type service to octoprint (#159852) 2025-12-28 15:11:44 +01:00
Joost Lekkerkerker
9a9c968cd2 Add integration_type service to opensky (#159859) 2025-12-28 15:10:59 +01:00
Joost Lekkerkerker
f0ddb9ff2c Add integration_type device to openhome (#159858) 2025-12-28 15:08:18 +01:00
Joost Lekkerkerker
8f6d88f517 Add integration_type hub to omnilogic (#159853) 2025-12-28 15:08:04 +01:00
Joost Lekkerkerker
20b0b6beb4 Add integration_type device to opengarage (#159856) 2025-12-28 15:07:25 +01:00
Joost Lekkerkerker
2980187206 Add integration_type service to openexchangerates (#159855) 2025-12-28 15:07:18 +01:00
Joost Lekkerkerker
97998ff61f Add integration_type device to onvif (#159854) 2025-12-28 15:07:13 +01:00
Bartosz Budzyński
b1189a33fe Increase ViCare heating max temperature to 60°C (#159847) 2025-12-28 15:05:14 +01:00
Joost Lekkerkerker
90cf2c7592 Add integration_type service to nws (#159849) 2025-12-28 15:04:17 +01:00
Joost Lekkerkerker
9b56229d34 Add integration_type hub to nexia (#159837) 2025-12-28 15:04:04 +01:00
Joost Lekkerkerker
848de08baa Add integration_type service to nextbus (#159838) 2025-12-28 15:03:35 +01:00
Joost Lekkerkerker
7d9bee8cea Add integration_type service to nightscout (#159840) 2025-12-28 15:01:24 +01:00
Joost Lekkerkerker
3a712f6512 Add integration_type hub to niko_home_control (#159841) 2025-12-28 15:01:00 +01:00
Joost Lekkerkerker
40d566f7f7 Add integration_type service to nintendo_parental_controls (#159843) 2025-12-28 15:00:11 +01:00
Joost Lekkerkerker
60ba1b0288 Add integration_type service to nmbs (#159844) 2025-12-28 14:59:49 +01:00
cdnninja
c1d77f00b3 vesync switch to async_write_ha_state (#159824) 2025-12-28 09:48:13 +01:00
Josef Zweck
96b2146f2b Fix translations for lamarzocco bbw numbers (#159787) 2025-12-27 18:55:16 +01:00
Andre Lengwenus
83b53e7bc7 Bump pypck to 0.9.9 (#159803) 2025-12-27 17:11:03 +01:00
Matthias Alphart
456a12f612 Update knx-frontend to 2025.12.25.200238 (#159748) 2025-12-27 12:12:17 +01:00
Bouwe Westerdijk
a8c732047d Bump plugwise to v1.11.2 (#159780) 2025-12-27 12:09:17 +01:00
Paul Tarjan
d9fe37e325 Address reviewer feedback on exception handling in hikvision (#159752) 2025-12-27 11:13:52 +01:00
blob810
c41d14fbe7 Support Shelly wave shutter with firmware 14.2.0 in Z-Wave (#159750)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-27 11:12:02 +01:00
Kevin Stillhammer
cf9444dc64 Add reauth to fressnapf_tracker (#157994) 2025-12-26 20:36:17 +01:00
Daniel Rauber
a447217b03 kostal_plenticore: Add state_class to Battery SoC sensor (#159776) 2025-12-26 20:31:47 +01:00
Pete Sage
e639ebc269 Exceptions during Sonos Unjoin action results in hung script (#159779) 2025-12-26 20:20:12 +01:00
Paul Tarjan
45ba7e0df1 Fix HikCamera.get_event_triggers() call with incorrect argument (#159760) 2025-12-26 11:33:53 +01:00
Manu
dfdcdbc856 Add integration type hub to Google Cast (#159757) 2025-12-26 11:26:31 +01:00
Manu
ea5df92ab9 Add integration type hub to Xiaomi Home (#159758) 2025-12-26 11:25:38 +01:00
Víctor Gurbani
9d1f500d65 Add state_class to Nuki battery sensor (#159756) 2025-12-26 10:37:16 +01:00
Joost Lekkerkerker
a82f500934 Add integration_type hub to moehlenhoff_alpha2 (#159694)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-25 21:56:55 +01:00
Joost Lekkerkerker
71d29ba28e Add integration_type hub to motioneye (#159698) 2025-12-25 21:54:32 +01:00
Joost Lekkerkerker
7910f33140 Add integration_type hub to microbees (#159690) 2025-12-25 21:53:24 +01:00
Joost Lekkerkerker
91ebeb84e7 Add integration_type service to monzo (#159697) 2025-12-25 21:52:54 +01:00
Joost Lekkerkerker
1664dd5702 Add integration_type device to mikrotik (#159691) 2025-12-25 21:52:37 +01:00
Joost Lekkerkerker
5e96ec820f Add integration_type service to monarch_money (#159695) 2025-12-25 21:50:17 +01:00
Joost Lekkerkerker
5eedef4920 Add integration_type hub to monoprice (#159696) 2025-12-25 21:50:03 +01:00
Joost Lekkerkerker
71728ba37e Add integration_type device to moat (#159693) 2025-12-25 21:48:54 +01:00
Allen Porter
5657bd11b8 Start reauth when roborock notices the MQTT session is unauthorized (#159719) 2025-12-25 21:47:42 +01:00
Joost Lekkerkerker
cd6bb861a8 Add integration_type service to mutesync (#159701) 2025-12-25 13:53:33 +01:00
Joost Lekkerkerker
3175c149c6 Add integration_type device to mpd (#159699) 2025-12-25 13:53:20 +01:00
Joost Lekkerkerker
bfe1e70e06 Add integration_type device to mystrom (#159703) 2025-12-25 13:52:33 +01:00
Joost Lekkerkerker
0a4c75951a Add integration_type device to nanoleaf (#159704) 2025-12-25 13:51:48 +01:00
Joost Lekkerkerker
fb6380157a Add integration_type hub to neato (#159705) 2025-12-25 13:51:19 +01:00
Retha Runolfsson
8d2b925131 Add support for switchbot art frame (#159710) 2025-12-25 13:50:46 +01:00
Ville Skyttä
5359a8bf26 Tidy up various Huawei LTE sensor values for display (#159728) 2025-12-25 13:15:06 +01:00
melo
be966f1196 Bump tuya-device-sharing-sdk to 0.2.7 (#159734) 2025-12-25 11:53:56 +01:00
Kamil Breguła
2d6ae8f907 Add sensors to Google Drive (#156167)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-12-24 21:24:36 -08:00
J. Nick Koston
2683b893c4 Bump aioesphomeapi to 43.6.0 (#159664) 2025-12-24 13:28:36 -10:00
Galorhallen
2b5de0db01 Update govee-local-api 2.3.0 (#159721) 2025-12-24 21:47:28 +01:00
Allen Porter
83d4f8eedc Fix Roborock repair issue behavior (#159718) 2025-12-24 21:00:36 +02:00
Joost Lekkerkerker
89247e9069 Add integration_type hub to mysensors (#159702) 2025-12-24 19:54:56 +01:00
Allen Porter
5f996e700f Bump python-roborock to 3.21.1 (#159660) 2025-12-24 09:27:41 -08:00
cdnninja
8f44eb6652 Improve VeSync startup error handling (#158126) 2025-12-24 18:17:16 +01:00
Joost Lekkerkerker
31dadd3102 Add integration_type service to mullvad (#159700) 2025-12-24 13:50:34 +01:00
Joost Lekkerkerker
4c74a57b63 Add integration_type service to minecraft_server (#159692) 2025-12-24 13:50:02 +01:00
Vincent Wolsink
750744f332 Fix display of target_humidity in Huum (#159683) 2025-12-24 13:49:31 +01:00
Joost Lekkerkerker
03442c5e51 Add integration_type hub to nest (#159706) 2025-12-24 13:47:57 +01:00
Matthias Alphart
26774c20c7 Update knx-frontend to 2025.12.24.74016 (#159678) 2025-12-24 12:51:55 +01:00
Matthias Alphart
29201ac5d6 Fix anglian water test snapshot (#159684) 2025-12-24 13:10:34 +02:00
Matthias Alphart
a6938127ea Fix inels config flow tests (#159688) 2025-12-24 13:08:20 +02:00
Retha Runolfsson
65d7f22072 Bump pySwitchbot to 0.75.0 (#159685) 2025-12-24 12:57:50 +02:00
dafal
0730c707e9 Bump bthome-ble to 3.17.0 (#159681) 2025-12-24 12:53:49 +02:00
Paul Tarjan
2e3eb0f9af Add uv.lock to .gitignore (#158754) 2025-12-24 00:06:59 +01:00
Ville Skyttä
2b5823c264 Huawei LTE sensor dynamic icon improvements (#159611) 2025-12-23 23:46:15 +01:00
cdnninja
95165022db Adjust vesync to follow action-setup (#157795) 2025-12-23 22:11:34 +01:00
Manuel Stahl
7c71c0377f Remove deprecated import from stiebel_eltron (#158110) 2025-12-23 22:05:22 +01:00
Jordan Harvey
b07b699e79 Add account selector to Anglian Water config flow (#158242) 2025-12-23 22:04:54 +01:00
Paul Tarjan
34db548725 Change Samsung TV WoL turn_on log from warning to debug (#158676) 2025-12-23 22:01:44 +01:00
Pete Sage
5150efd63f Create issue for Sonos when Sonos system does not have UPnP enabled (#159330) 2025-12-23 22:00:28 +01:00
Louis Christ
0525c75686 Support media player grouping in bluesound integration (#159455)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-23 21:48:28 +01:00
Kamil Breguła
7c14862f62 Normalize unique ID in WLED (#157901)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-12-23 21:29:20 +01:00
Rene Nulsch
19f8d9d41b Fix ZeroDivisionError for inverse unit conversions (#159161) 2025-12-23 21:25:19 +01:00
Andrew Jackson
af1218876c Add Transmission get_torrents service and codeowner (#159211)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-23 21:11:47 +01:00
Lukas Gill
9715a7cc32 Add light level data to switchbot presence sensor (#159356) 2025-12-23 20:57:41 +01:00
epenet
b87b72ab01 Repair flow description placeholders are optional (#159385) 2025-12-23 20:31:04 +01:00
Jan-Philipp Benecke
0f3f16fabe Remove migration of wrong encoded folder path from WebDAV (#159457) 2025-12-23 20:30:24 +01:00
W7RZL
85311e3def Add solar production sensors to neurio_energy (#159533) 2025-12-23 20:26:43 +01:00
epenet
a33a4b6d9d Deprecate pyserial-asyncio in requirements manager (#159368)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-12-23 20:25:41 +01:00
Petro31
02f412feb1 Update template sensor tests to use new framework (#159466)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-23 20:21:57 +01:00
Raphael Hehl
b3c78d4207 Improve date handling in UniFi Protect media source (#159491)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-23 20:21:21 +01:00
Sab44
a3dec29c72 Add Computer Name to device in Libre Hardware Monitor (#159342) 2025-12-23 19:54:35 +01:00
starkillerOG
aa20a74a76 Bump reolink_aio to 0.18.0 (#159649) 2025-12-23 20:24:24 +02:00
Matthias Alphart
c0fa6ad2e0 Support KNX scene entity configuration from UI (#159494)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-23 18:56:02 +01:00
Raphael Hehl
5107b7012d Add helper utility for patching Pydantic model methods in UniFi Protect tests (#159346)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-23 18:54:56 +01:00
Duco Sebel
bcc5985c8b Enable HomeWizard Battery group mode by default when device controls batteries (#159493)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-23 18:54:28 +01:00
Marcello
5933c09a1d Add Fluss+ Button integration (#139925)
Co-authored-by: NjDaGreat <1754227@students.wits.ac.za>
Co-authored-by: NjeruFluss <161302608+NjeruFluss@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-23 18:48:22 +01:00
Robin Lintermann
5f1e6f3633 Bump pysmarlaapi to 0.9.3 (#159638) 2025-12-23 15:27:10 +01:00
Jan Klausa
6bd8d123ed Add support for SwitchBot Ceiling Lights (#159072) 2025-12-23 15:25:22 +01:00
Ville Skyttä
50a51b5ecc Improve upnp sensor icons (#159496) 2025-12-23 14:12:49 +01:00
wollew
c115b418ac Handle auth errors in velux integration and add reauth flow (#159596)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-23 14:11:40 +01:00
Maikel Punie
2160827a50 Refactor Velbus sensors (#159600)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-23 13:52:08 +01:00
wollew
82d84d7adf raise HomeAssistantError when velux gateway reboot fails (#159585)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-23 13:41:49 +01:00
Maikel Punie
3e498d289b Velbus make sure the services throw exceptions (#159583) 2025-12-23 13:39:16 +01:00
mettolen
e6d8092c37 Add binary sensors to Saunum integration (#159608) 2025-12-23 13:35:27 +01:00
mettolen
2e4f95c099 Add number entity to Airobot integration (#159595) 2025-12-23 13:33:46 +01:00
Ville Skyttä
9f54b09423 Do not create Huawei LTE sensors having None values (#159612) 2025-12-23 13:31:07 +01:00
Allen Porter
8361d65d23 Bump python-roborock to 3.20.1 (#159621) 2025-12-23 12:59:47 +02:00
Marc Mueller
7a82aa4803 Revert "Exempt pyparsing from license check (#159605)" (#159631) 2025-12-23 12:58:56 +02:00
Artur Pragacz
02ab11c1bd Mark entities as unavailable in Onkyo (#159521) 2025-12-23 08:39:34 +01:00
Brett Adams
64f0a615df Bump teslemetry-stream to 0.9.0 (#159617) 2025-12-22 17:21:43 -08:00
Craig Callender
3e889616f2 Remove 'hair_pinning' from Tailscale (#156728)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-22 23:47:10 +00:00
Aviad Levy
bdbe2a6346 Fix allowlist dir requirement in download file handling for Telegram bot (#159615) 2025-12-23 00:09:00 +01:00
Aviad Levy
016d492342 Add download file service to Telegram bot integration (#154625)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-22 23:12:51 +01:00
karwosts
9ce46c0937 Redesign frontend.set_theme service form (#157866)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-12-22 23:03:06 +01:00
Robert Svensson
8d96aee96e Bump axis to v66 fixing an issue with latest xmltodict (#159604)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-22 21:51:44 +00:00
Mary
7083a0fdb7 Fix typo in test names (exception) (#159591) 2025-12-22 21:21:36 +01:00
Mary
e3976923b2 Fix test name typo (trailing underscore) (#159592) 2025-12-22 21:16:01 +01:00
Mary
0b20417895 Fix Ecoforest unknown alarm translation key (#159594) 2025-12-22 21:08:27 +01:00
Jordan Harvey
ed46c30b10 Bump pynintendoparental to 2.3.0 (#159571)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-22 21:05:09 +01:00
Ross Patterson
38f4cf0575 Clean up docstring copied word typo (#159581) 2025-12-22 21:04:45 +01:00
dontinelli
7b60cc3a80 Disable quoted cookies for compatibility with older SolarLog devices (#157839) 2025-12-22 19:41:46 +01:00
TheJulianJES
fe0c92b6c5 Exempt pyparsing from license check (#159605) 2025-12-22 18:47:02 +01:00
Erik Montnemery
c4386b4360 Add additional numerical climate triggers (#159471) 2025-12-22 17:36:27 +00:00
Erik Montnemery
d4d26bccc1 Add numerical humidifier triggers (#159472)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-22 17:30:06 +00:00
Raphael Hehl
550b7bf7ba Bump uiprotect to 7.33.3 (#159593) 2025-12-22 17:44:18 +01:00
Erik Montnemery
6ff472ff87 Add light brightness triggers (#159473) 2025-12-22 15:54:53 +00:00
Marc Hörsken
ca30d8b1c2 Add support for load switches to WMS WebControl pro (#151047)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-22 15:12:28 +01:00
Maikel Punie
aae98a77d5 Bump valbusaio to 2025.12.0 (#159578) 2025-12-22 14:44:46 +01:00
Matrix
30b7b24ddd Bump yolink-api to 0.5.9 (#159587) 2025-12-22 14:19:04 +01:00
wollew
a972a6d43a Make velux rain sensor unavailable if update fails (#159520)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-22 14:06:54 +01:00
Robert Resch
6e06c015df Bump go2rtc-client to 0.4.0 (#159516) 2025-12-22 12:25:47 +01:00
wollew
01c3e88e0f provide Squeezebox player sensor for next alarm timestamp (#155788) 2025-12-22 11:53:38 +01:00
Magnus
fd9064376a Bump melissa to 3.0.3 (#159557) 2025-12-22 09:08:03 +01:00
dependabot[bot]
9eb5d452cf Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#159577)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-22 09:04:01 +01:00
J. Nick Koston
966209e4b6 Bump aioesphomeapi to 43.4.0 (#159524) 2025-12-21 21:25:23 +01:00
Frank
a09ac94db9 Correct spelling of property (#159549) 2025-12-21 21:22:28 +01:00
Allen Porter
0710cf3e6b Redact additional unnecessary diagnostic fields (#159546) 2025-12-21 09:50:51 -08:00
Joakim Plate
a81f2a63c0 Ensure all base component dependencies are added (#157428) 2025-12-21 15:24:56 +01:00
Manu
6ef2d0d0a3 Add integration type hub to Xbox (#159528) 2025-12-21 07:59:03 +01:00
Manu
911ea67a6d Change integration type to hub in PlayStation Network (#159529) 2025-12-21 07:58:49 +01:00
Josef Zweck
28dc32d5dc Follow through with deprecation in async_config_entry_first_refresh (#158775) 2025-12-21 07:56:35 +01:00
Abílio Costa
c95416cb48 Add scene activated trigger (#159226)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-12-21 01:07:00 +00:00
wollew
7dc9084f06 Velux action setup (#159502) 2025-12-20 19:49:15 +01:00
Svetoslav
39ba36d642 Fix syntax error in mute_volume method (#159458) 2025-12-20 19:45:02 +01:00
Álvaro Fernández Rojas
5009560f57 Update aioqsw to v0.4.2 (#159467) 2025-12-20 19:43:20 +01:00
Niracler
41e88573bb Enhance Sunricher DALI with stale-device cleanup (#156015)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-20 18:26:57 +01:00
Markus Jacobsen
27ee986b1b Add Beoremote One diagnostics to Bang & Olufsen (#159447) 2025-12-20 18:25:04 +01:00
Lukas
c9d21c1851 Pooldose: Add parallel updates (Silver Qly Scale) (#159479) 2025-12-20 18:23:25 +01:00
wollew
2afbdc5757 add gateway disconnect on unload of velux integration (#159497) 2025-12-20 18:16:58 +01:00
Joost Lekkerkerker
14cb8af9fe Add integration_type service to meteoclimatic (#159488) 2025-12-20 15:16:31 +01:00
Joost Lekkerkerker
74ae0f8297 Add integration_type service to metoffice (#159489) 2025-12-20 15:14:18 +01:00
Paul Tarjan
3050a5c896 Support NVR Hikvision devices (#159253) 2025-12-20 10:08:48 +01:00
Raphael Hehl
9f886e66c7 Update UniFi Protect select entities to use snake_case state values with proper translations (#159284)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-20 10:07:21 +01:00
Tom Harris
3c752d4516 Bump insteon panel to 0.6.0 to fix dialog button issues (#159449) 2025-12-20 10:05:03 +01:00
Raphael Hehl
e4bfdf5b30 Add quality scale configuration for UniFi Protect integration (#157568)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-20 10:03:02 +01:00
Artur Pragacz
3e43424a73 Add myself as codeowner to intent script (#159454) 2025-12-20 10:00:58 +01:00
Matthias Alphart
0db9dcfd1c Fix knx translation typos (#159486) 2025-12-20 09:53:45 +01:00
J. Nick Koston
5b5850224a Bump yalexs-ble to 3.2.4 (#159476) 2025-12-19 14:05:07 -10:00
Erik Montnemery
065b0eb5b2 Fix siren entity triggers (#159474) 2025-12-19 22:45:32 +01:00
Michael
6a1d86d5db Add domain driven triggers to lock platform (#159327)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:34:33 +01:00
Petro31
f99a73ef28 Modernize template weather platform and add config flow (#156399) 2025-12-19 22:28:26 +01:00
Michael
0436d30062 Add turned off and turned on triggers to siren platform (#158847)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:15:06 +01:00
Erik Montnemery
24b6b5452b Add trigger climate.target_temperature_crossed_threshold (#159461)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-19 21:57:10 +01:00
Erik Montnemery
8b91ebfe30 Add test of error handling in numerical_attribute_changed triggers (#159469) 2025-12-19 21:40:56 +01:00
Bram Kragten
62ec64c3fe 2025.12.4 (#159460) 2025-12-19 18:54:49 +01:00
Bram Kragten
cbc6306963 Merge branch 'master' into rc 2025-12-19 18:27:05 +01:00
Bram Kragten
e098acfa69 Bump version to 2025.12.4 2025-12-19 18:12:22 +01:00
Bram Kragten
52630ccca1 Update frontend to 20251203.3 (#159451) 2025-12-19 18:10:28 +01:00
Robert Resch
3001dcb8ff Remove users refresh tokens when the user get's deactivated (#159443) 2025-12-19 18:10:27 +01:00
Allen Porter
cec5134369 Bump python-roborock to 3.19.0 (#159404) 2025-12-19 18:10:26 +01:00
puddly
80f2889e1f Bump ZHA to 0.0.81 (#159396) 2025-12-19 18:10:25 +01:00
Simone Chemelli
188c98fd08 Align format of voltmeter strings for Shelly (#159394) 2025-12-19 18:10:25 +01:00
Artur Pragacz
e086e013d5 Do not trigger reauth for addon in Music Assistant (#159372) 2025-12-19 18:10:24 +01:00
Simone Chemelli
3c20df961e Add missing strings for Shelly voltmeter sensor (#159332) 2025-12-19 18:10:23 +01:00
Allen Porter
9f31d95940 Fix AttributeError in Roborock Empty Mode entity (#159278)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 18:10:22 +01:00
Andre Lengwenus
d5cbc6efca Bump pypck to 0.9.8 (#159277) 2025-12-19 18:10:21 +01:00
Luke Lashley
793877bfeb Bump python-roborock to 3.18.0 (#159271) 2025-12-19 18:10:21 +01:00
Andre Lengwenus
692847d9a8 Fix incorrect status updates for lcn (#159251) 2025-12-19 18:10:19 +01:00
Richard Polzer
31785bf68f Bump ekey-bionyxpy to version 1.0.1 (#159196) 2025-12-19 18:10:18 +01:00
Åke Strandberg
d17ed3ed95 Handle missing Miele status codes gracefully (#159124) 2025-12-19 18:10:17 +01:00
Pete Sage
7bbeb2a006 Bump soco to 0.30.13 for Sonos (#159123) 2025-12-19 18:10:16 +01:00
Jordan Harvey
7275be4629 Bump pynintendoparental 2.1.3 (#159120)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-19 18:10:16 +01:00
Pete Sage
37a32bf27d Sonos increase wait for groups timeout (#159108) 2025-12-19 18:10:14 +01:00
Pete Sage
00b7138c43 Sonos fix media player join to avoid race condition (#159106) 2025-12-19 18:10:13 +01:00
PaulCavill
1b464e799b Improve icloud reauth flow (#159081) 2025-12-19 18:10:12 +01:00
TimL
1a56855158 Bump pysmlight to v0.2.13 (#159075)
Co-authored-by: Tim Lunn <tim@feathertop.org>
2025-12-19 18:10:11 +01:00
Bram Kragten
0dac52cbe4 Bump aiodns to 3.6.1 (#159073) 2025-12-19 18:09:13 +01:00
Allen Porter
63cb220a8f Fix slow event state updates for remote calendar (#159058) 2025-12-19 18:02:13 +01:00
Kevin Fronczak
af72bc4d2a Bump blinkpy to 0.25.2 (#159049) 2025-12-19 18:02:12 +01:00
Xidorn Quan
108d94ab06 Bump aioasuswrt to 1.5.4 (#159038) 2025-12-19 18:02:11 +01:00
Allen Porter
d64313cd28 Add exception handling for rate limited or unauthorized MQTT requests (#158997) 2025-12-19 18:02:10 +01:00
Petro31
b608dcb2eb Update unnecessary error logging of unknown and unavailable source states from mold indicator (#158979) 2025-12-19 18:02:10 +01:00
Allen Porter
e0fa5db218 Bump ical to 12.1.2 (#158965) 2025-12-19 18:02:09 +01:00
Jan Bouwhuis
96d2ecf250 Assume cover or valve is always "running" in google assistant when the state is assumed or the position is reported to allow it to be be stopped (#158919) 2025-12-19 18:02:08 +01:00
Aidan Timson
b0fac94666 Update systembridgeconnector to 5.2.4, fix media source (#158917) 2025-12-19 18:02:07 +01:00
Andrew Jackson
8902ba9f1d Bump aiomealie to 1.1.1 and statically define mealplan entry types (#158907) 2025-12-19 18:02:06 +01:00
Bouwe Westerdijk
581919ccb4 Revert adding entity_category to Plugwise thermostat schedule select (#158901) 2025-12-19 18:02:05 +01:00
Magnus
7714b51c21 Bump aioasuswrt 1.5.3 (#158882) 2025-12-19 18:02:04 +01:00
Jordan Harvey
8ee94f829a Bump pynintendoparental to 2.1.1 (#158779) 2025-12-19 18:02:03 +01:00
Paul Tarjan
73734d2ff2 Fix Sonos speaker async_offline assertion failure (#158764) 2025-12-19 18:02:02 +01:00
Paul Tarjan
b7d4c3c5d1 Suppress verbose UPnP subscription error logs (#158677) 2025-12-19 18:02:01 +01:00
Allen Porter
5d30fc3436 Suppress roborock failures under some unavailability threshold (#158673)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 18:02:00 +01:00
Jordan Harvey
4cced81f86 Update pynintendoparental to 2.1.0 (#158487) 2025-12-19 18:01:58 +01:00
Thomas D
81d10d02de Enable volvo engine status for all engine types (#158437) 2025-12-19 18:01:57 +01:00
Jordan Harvey
73484cb8fb Update pynintendoparental to 2.0.0 (#158285) 2025-12-19 18:01:56 +01:00
starkillerOG
d0aaac0382 Do not check Reolink firmware at start (#158275) 2025-12-19 18:01:55 +01:00
Federico Imberti
67550731b3 Prevent empty aliases in registries (#156061)
Co-authored-by: J. Diego Rodríguez Royo <jdrr1998@hotmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-19 18:01:54 +01:00
Franck Nijhof
04746b6843 2025.12.3 (#158811) 2025-12-12 19:10:33 +01:00
Magnus
0547153730 Bump aioasuswrt to 1.5.2 (#158727) 2025-12-12 17:37:17 +00:00
Franck Nijhof
eb024b4dde Bump version to 2025.12.3 2025-12-12 17:23:29 +00:00
Joost Lekkerkerker
1d4817608e Bump pySmartThings to 3.5.1 (#158795) 2025-12-12 17:23:16 +00:00
Manu
a37ca293e1 Increase Xbox update interval to 15 seconds and refactor title data handling (#158780) 2025-12-12 17:23:15 +00:00
Josef Zweck
f3dbddee16 Bump pylamarzocco to 2.2.4 (#158774) 2025-12-12 17:20:51 +00:00
Josef Zweck
b26681ee88 Bump pylamarzocco to 2.2.3 (#158104) 2025-12-12 17:20:49 +00:00
Allen Porter
effe72bfda Bump ical to 12.1.1 (#158770) 2025-12-12 17:19:13 +00:00
cdutr
076835ca1c Migrate Blink component to use hardware_id instead of device_id (#158765) 2025-12-12 17:19:12 +00:00
Thomas55555
4b9b1e611a Bump google air quality api to 2.0.2 (#158742) 2025-12-12 17:19:11 +00:00
ndrwrbgs
0b4ea42810 Update advanced_options display text for MQTT (#158728) 2025-12-12 17:19:09 +00:00
johanzander
8907608345 Add state_class to Growatt power and energy sensors (#158705)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 17:19:08 +00:00
J. Nick Koston
356ee07e22 Pin pycares to 4.11.0 (#158695) 2025-12-12 17:19:07 +00:00
Allen Porter
bee3ee6320 Bump python-roborock to 3.12.2 (#158572) 2025-12-12 17:19:05 +00:00
Andrew Jackson
fb72ff9bd0 Add measurement state class to ohme sensors (#158541) 2025-12-12 17:19:04 +00:00
bestycame
412e05d8da Bump hanna-cloud to version 0.0.7 (#158536)
Co-authored-by: Olivier d'Otreppe <odotreppe@abbove.com>
2025-12-12 17:19:03 +00:00
Yevhenii Vaskivskyi
58ee8e863e Bump asusrouter to 1.21.3 (#158492) 2025-12-12 17:19:01 +00:00
Ludovic BOUÉ
e3a47bfc51 Fix Matter Door Lock Operating Mode select entity (#158468) 2025-12-12 17:19:00 +00:00
Allen Porter
a6cdacc8fe Improve Roborock exception logging behavior for Zeo/Dyad devices (#158465)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 17:18:58 +00:00
epenet
dd0425ab8e Add Tuya local_strategy to Tuya diagnostic (#158450) 2025-12-12 17:18:57 +00:00
Samuel Xiao
1d289c0083 Switchbot Cloud: Fixed binary sensors didn't update automatically (#158434)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-12 17:18:56 +00:00
Allen Porter
70786a1d90 Fix roborock off peak electricity timer (#158292) 2025-12-12 17:18:54 +00:00
Michel D'Astous
293eb69788 Fix webhook exception when empty json data is sent (#158254) 2025-12-12 17:18:53 +00:00
Kira
71d92291d1 Bump blinkpy to 0.25.1 (#158135)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-12 17:18:52 +00:00
Andre Lengwenus
726de64394 Bump pypck to 0.9.7 (#158089) 2025-12-12 17:18:50 +00:00
epenet
de04f22f89 Improve Tuya HVACMode handling (#158042)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 17:18:49 +00:00
Jan Bouwhuis
9e8cc3a65b Move translatable URL out of strings.json for knx integration (#155244) 2025-12-12 17:04:30 +00:00
Franck Nijhof
27fa92b607 Fix Tuya BitmapTypeInformation parsing (#158475) 2025-12-10 17:06:50 +01:00
epenet
ce5c5c5eb7 Fix Tuya BitmapTypeInformation parsing 2025-12-09 16:29:25 +00:00
Franck Nijhof
88e29df8eb 2025.12.2 (#158274) 2025-12-08 22:35:39 +01:00
Franck Nijhof
a2b5744696 Bump version to 2025.12.2 2025-12-08 20:45:22 +00:00
Marcel van der Veldt
201c3785f5 Skip check for onboarding done in Music Assistant integration (#158270) 2025-12-08 20:17:05 +00:00
Paul Bottein
24de26cbf5 Update frontend to 20251203.2 (#158259) 2025-12-08 20:17:04 +00:00
andreimoraru
ac0a544829 Bump yt-dlp to 2025.12.08 (#158253) 2025-12-08 20:17:03 +00:00
Petro31
1a11b92f05 Fix multiple top-level support for template integration (#158244) 2025-12-08 20:17:01 +00:00
epenet
ab0811f59f Fix teslemetry service description placeholders (#158240) 2025-12-08 20:17:00 +00:00
epenet
68711b2f21 Fix yeelight service description placeholders (#158239) 2025-12-08 20:16:59 +00:00
epenet
886e2b0af1 Fix zwave_js service description placeholders (#158236) 2025-12-08 20:16:57 +00:00
Thomas55555
7492b5be75 Bump google air quality api to 2.0.0 (#158234) 2025-12-08 20:16:56 +00:00
Jan Bouwhuis
e4f1565e3c Fix description placeholders for system_bridge (#158232) 2025-12-08 20:16:54 +00:00
Paul Bottein
7f37412199 Be more specific about winter mode in the description (#158230)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-12-08 20:16:53 +00:00
Allen Porter
eaef0160a2 Bump python-roborock to 3.10.10 (#158212) 2025-12-08 20:16:52 +00:00
Harvey
f049c425ba Bump HueBLE to 2.1.0 (#158197) 2025-12-08 20:16:50 +00:00
Yevhenii Vaskivskyi
50eee75b8f Bump asusrouter to 1.21.1 (#158192) 2025-12-08 20:16:48 +00:00
Åke Strandberg
81e47f6844 Bump pymiele dependency to 0.6.1 (#158177) 2025-12-08 20:16:46 +00:00
Åke Strandberg
ffebbab020 Add program id codes for Miele WQ1000 (#158175) 2025-12-08 20:16:45 +00:00
Manu
9824bdc1c9 Fix secure URLs for promotional game media in Xbox integration (#158162) 2025-12-08 20:16:44 +00:00
Allen Porter
a933d4a0eb Ensure Roborock disconnects mqtt on unload/stop (#158144)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 20:16:42 +00:00
Shay Levy
f7f7f9a2de Revert "Remove Shelly redundant device entry check for sleepy devices" (#158108) 2025-12-08 20:16:41 +00:00
Petro31
aac412f3a8 Fix legacy template entity_id field in migration (#158105)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-08 20:16:39 +00:00
omrishiv
660a14e78d fix Lutron Caseta smart away subscription (#158082)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-12-08 20:16:38 +00:00
Franck Nijhof
4aa3f0a400 2025.12.1 (#158071) 2025-12-05 22:09:38 +01:00
Franck Nijhof
0b52c806d4 Bump version to 2025.12.1 2025-12-05 20:32:57 +00:00
Paul Bottein
bbe27d86a1 Update frontend to 20251203.1 (#158069) 2025-12-05 20:32:28 +00:00
Raphael Hehl
fb7941df1d Bump uiprotect to 7.33.2 (#158057) 2025-12-05 20:32:27 +00:00
Petro31
c46e341941 Fix inverted kelvin issue (#158054) 2025-12-05 20:32:25 +00:00
Jan Bouwhuis
2e3a9e3a90 Move example image path out of translatable strings (#158053) 2025-12-05 20:32:24 +00:00
Jan Bouwhuis
55c5ecd28a Move lametric URLs out of strings.json (#158051) 2025-12-05 20:32:22 +00:00
Denis Shulyaka
e50e2487e1 Replace deprecated preview image model (#158048) 2025-12-05 20:32:21 +00:00
Maciej Bieniek
74e118f85c Do not create restart button for sleeping gen2+ Shelly devices (#158047) 2025-12-05 20:32:19 +00:00
Joost Lekkerkerker
39a62ec2f6 Prevent entsoe from loading (#158036)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-05 20:32:18 +00:00
Petro31
1310efcb07 Fix missing template key in deprecation repair (#158033) 2025-12-05 20:32:16 +00:00
hanwg
53af592c2c Improve action descriptions for Telegram bot (#158022) 2025-12-05 20:32:15 +00:00
TheJulianJES
023987b805 Change ZHA strings for incorrect adapter state (#158021)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-12-05 20:32:13 +00:00
Allen Porter
5b8fb607b4 Bump python-roborock to 3.10.2 (#158020) 2025-12-05 20:32:12 +00:00
Mark Adkins
252f6716ff SharkIQ dep upgrade v1.5.0 (#158015) 2025-12-05 20:32:11 +00:00
Paul Tarjan
bf78e28f83 Fix doorbird duplicate unique ID generation (#158013)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-12-05 20:32:09 +00:00
David Bonnes
22706d02a7 Bump evohome-async to 1.0.6 (#158005) 2025-12-05 20:32:08 +00:00
Abílio Costa
5cff0e946a Bump oralb-ble to 1.0.2 (#157992) 2025-12-05 20:32:06 +00:00
Luke Lashley
6cbe2ed279 Bump python-Roborock to 3.10.0 (#157980) 2025-12-05 20:32:04 +00:00
Paul Bottein
fb0f5f52b2 Add subscribe preview feature endpoint to labs (#157976) 2025-12-05 20:32:03 +00:00
Jan Bouwhuis
5c422bb770 Move out example URL and IP of strings.json for reolink (#157970) 2025-12-05 20:32:01 +00:00
Jan Bouwhuis
fd1bc07b8c Move pilight URL out of strings.json (#157967) 2025-12-05 20:31:59 +00:00
Petro31
97a019d313 Update template deprecation to be more explicit (#157965) 2025-12-05 20:31:58 +00:00
epenet
8ae8a564c2 Fix unit parsing in Tuya climate entities (#157964) 2025-12-05 20:31:56 +00:00
Jan Bouwhuis
2f72f57bb7 Move out zwave_js api docs url from strings.json (#157959) 2025-12-05 20:31:55 +00:00
Jan Bouwhuis
e928e3cb54 Move Yeelight URLs out of translatable strings for action descriptions (#157957) 2025-12-05 20:31:53 +00:00
Petro31
b0e2109e15 Fix template migration errors (#157949) 2025-12-05 20:31:51 +00:00
Jordan Harvey
b449c6673f Add pyanglianwater to Anglian Water loggers (#157947) 2025-12-05 20:31:50 +00:00
Manu
877ad38ac3 Convert image URLs to secure URLs in Xbox integration (#157945) 2025-12-05 20:31:48 +00:00
Jan Bouwhuis
229f45feae Move translatable URL from rainmachine push_weather_data action description (#157941)
Co-authored-by: Michelle "MishManners®™" Duke <36594527+mishmanners@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-05 20:31:47 +00:00
Jordan Harvey
a535d1f4eb Set account number as required for Anglian Water config entry (#157939) 2025-12-05 20:31:46 +00:00
Jan Bouwhuis
d4adc00ae6 Move out URL of Xiaomy_aquara from strings.json (#157937)
Co-authored-by: Michelle "MishManners®™" Duke <36594527+mishmanners@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-05 20:31:44 +00:00
starkillerOG
ba141f9d1d Bump reolink_aio to 0.17.1 (#157929) 2025-12-05 20:31:41 +00:00
cdnninja
72be9793a4 Fix VeSync binary sensor discovery (#157898) 2025-12-05 20:31:40 +00:00
Luke Lashley
5ae7cc5f84 Correctly pass MopParserConfig for Roborock (#157891) 2025-12-05 20:31:39 +00:00
Jan Bouwhuis
d01a469b46 Move teslemetry time-of-use URL out of strings.json (#157874) 2025-12-05 20:31:37 +00:00
TheJulianJES
9f07052874 Display error when forming new ZHA network fails (#157863) 2025-12-05 20:31:35 +00:00
David Rapan
b9bc9d3fc2 Fix Starlink's ever updating uptime (#155574)
Signed-off-by: David Rapan <david@rapan.cz>
2025-12-05 20:31:34 +00:00
Max Michels
1e180cd5ee Move telegram-bot URLs out of strings.json (#155130)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: jbouwh <jan@jbsoft.nl>
2025-12-05 20:31:32 +00:00
Quentin Ulmer
dc9cdd13b1 Fix Rituals Perfume Genie (#151537)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-05 20:31:30 +00:00
810 changed files with 53123 additions and 7929 deletions

View File

@@ -100,7 +100,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -111,7 +111,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -197,7 +197,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build variables
id: vars
@@ -405,7 +405,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.1"
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
# 10.3 is the oldest supported version

1
.gitignore vendored
View File

@@ -92,6 +92,7 @@ pip-selfcheck.json
venv
.venv
Pipfile*
uv.lock
share/*
/Scripts/

10
CODEOWNERS generated
View File

@@ -516,6 +516,8 @@ build.json @home-assistant/supervisor
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
/tests/components/firmata/ @DaAwesomeP
/homeassistant/components/fish_audio/ @noambav
/tests/components/fish_audio/ @noambav
/homeassistant/components/fitbit/ @allenporter
/tests/components/fitbit/ @allenporter
/homeassistant/components/fivem/ @Sander0542
@@ -530,6 +532,8 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -794,6 +798,8 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
@@ -1693,8 +1699,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
/tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/trend/ @jpbede
/tests/components/trend/ @jpbede
/homeassistant/components/triggercmd/ @rvmey

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==4.2.2"]
"requirements": ["accuweather==5.0.0"]
}

View File

@@ -15,12 +15,10 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, 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 .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity
PARALLEL_UPDATES = 0
@@ -56,8 +54,7 @@ async def async_setup_entry(
for coordinator in system_coordinators.values():
status = coordinator.data
name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.append(ActronSystemClimate(coordinator))
entities.extend(
ActronZoneClimate(coordinator, zone)
@@ -68,10 +65,9 @@ async def async_setup_entry(
async_add_entities(entities)
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
class ActronAirClimateEntity(ClimateEntity):
"""Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -83,43 +79,17 @@ class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEn
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Representation of the Actron Air system."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, name)
serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
self._attr_unique_id = self._serial_number
@property
def min_temp(self) -> float:
@@ -168,7 +138,7 @@ class ActronSystemClimate(BaseClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -182,7 +152,7 @@ class ActronSystemClimate(BaseClimateEntity):
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(BaseClimateEntity):
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Representation of a zone within the Actron Air system."""
_attr_supported_features = (
@@ -197,18 +167,8 @@ class ActronZoneClimate(BaseClimateEntity):
zone: ActronAirZone,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, zone.title)
serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
super().__init__(coordinator, zone)
self._attr_unique_id: str = self._zone_identifier
@property
def min_temp(self) -> float:
@@ -256,4 +216,4 @@ class ActronZoneClimate(BaseClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs["temperature"])
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -8,6 +8,7 @@ from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
@@ -15,7 +16,7 @@ from actron_neo_api import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
@@ -70,6 +71,12 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
) from err
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()

View File

@@ -0,0 +1,63 @@
"""Base entity classes for Actron Air integration."""
from actron_neo_api import ActronAirZone
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return not self.coordinator.is_device_stale()
class ActronAirAcEntity(ActronAirEntity):
"""Base class for Actron Air entities."""
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._serial_number)},
name=coordinator.data.ac_system.system_name,
manufacturer="Actron Air",
model_id=coordinator.data.ac_system.master_wc_model,
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
serial_number=self._serial_number,
)
class ActronAirZoneEntity(ActronAirEntity):
"""Base class for Actron Air zone entities."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirZone,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._zone_id: int = zone.zone_id
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._zone_identifier)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
)

View File

@@ -51,6 +51,9 @@
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
}
}

View File

@@ -7,12 +7,10 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
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 .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity
PARALLEL_UPDATES = 0
@@ -74,10 +72,9 @@ async def async_setup_entry(
)
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Actron Air switch."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
@@ -90,11 +87,6 @@ class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntit
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.serial_number)},
manufacturer="Actron Air",
name=coordinator.data.ac_system.system_name,
)
@property
def is_on(self) -> bool:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling",
"loggers": ["adax", "adax_local"],
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"]
}

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -175,6 +175,42 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Verify the device ID matches the existing config entry
await self.async_set_unique_id(info.device_id)
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=user_input,
title=info.title,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, reconfigure_entry.data
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@@ -0,0 +1,9 @@
{
"entity": {
"number": {
"hysteresis_band": {
"default": "mdi:delta"
}
}
}
}

View File

@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "silver",
"requirements": ["pyairobotrest==0.1.0"]
"requirements": ["pyairobotrest==0.2.0"]
}

View File

@@ -0,0 +1,99 @@
"""Number platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotNumberEntityDescription(NumberEntityDescription):
"""Describes Airobot number entity."""
value_fn: Callable[[AirobotDataUpdateCoordinator], float]
set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]]
NUMBERS: tuple[AirobotNumberEntityDescription, ...] = (
AirobotNumberEntityDescription(
key="hysteresis_band",
translation_key="hysteresis_band",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=HYSTERESIS_BAND_MIN / 10.0,
native_max_value=HYSTERESIS_BAND_MAX / 10.0,
native_step=0.1,
value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band,
set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band(
value
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot number platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotNumber(coordinator, description) for description in NUMBERS
)
class AirobotNumber(AirobotEntity, NumberEntity):
"""Representation of an Airobot number entity."""
entity_description: AirobotNumberEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
try:
await self.entity_description.set_value_fn(self.coordinator, value)
except AirobotError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_value_failed",
translation_placeholders={"error": str(err)},
) from err
else:
await self.coordinator.async_request_refresh()

View File

@@ -48,7 +48,7 @@ rules:
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Single device integration, no dynamic device discovery needed.
@@ -57,8 +57,8 @@ rules:
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration doesn't have any cases where raising an issue is needed.

View File

@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "Device ID does not match the existing configuration. Please use the correct device credentials."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -28,6 +30,19 @@
},
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "Device ID"
},
"data_description": {
"host": "[%key:component::airobot::config::step::user::data_description::host%]",
"password": "[%key:component::airobot::config::step::user::data_description::password%]",
"username": "[%key:component::airobot::config::step::user::data_description::username%]"
},
"description": "Update your Airobot thermostat connection details. Note: The Device ID must remain the same as the original configuration."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -44,6 +59,11 @@
}
},
"entity": {
"number": {
"hysteresis_band": {
"name": "Hysteresis band"
}
},
"sensor": {
"air_temperature": {
"name": "Air temperature"
@@ -74,6 +94,9 @@
},
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}."
},
"set_value_failed": {
"message": "Failed to set value: {error}"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==10.0.0"]
"requirements": ["aioamazondevices==11.0.2"]
}

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from aiohttp import CookieJar
from pyanglianwater import AnglianWater
@@ -30,14 +30,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
}
)
async def validate_credentials(
auth: MSOB2CAuth, account_number: str
) -> str | MSOB2CAuth:
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
"""Validate the provided credentials."""
try:
await auth.send_login_request()
@@ -46,6 +43,33 @@ async def validate_credentials(
except Exception:
_LOGGER.exception("Unexpected exception")
return "unknown"
return auth
def humanize_account_data(account: dict) -> str:
"""Convert an account data into a human-readable format."""
if account["address"]["company_name"] != "":
return f"{account['account_number']} - {account['address']['company_name']}"
if account["address"]["building_name"] != "":
return f"{account['account_number']} - {account['address']['building_name']}"
return f"{account['account_number']} - {account['address']['postcode']}"
async def get_accounts(auth: MSOB2CAuth) -> list[selector.SelectOptionDict]:
"""Retrieve the list of accounts associated with the authenticated user."""
_aw = AnglianWater(authenticator=auth)
accounts = await _aw.api.get_associated_accounts()
return [
selector.SelectOptionDict(
value=str(account["account_number"]),
label=humanize_account_data(account),
)
for account in accounts["result"]["active"]
]
async def validate_account(auth: MSOB2CAuth, account_number: str) -> str | MSOB2CAuth:
"""Validate the provided account number."""
_aw = AnglianWater(authenticator=auth)
try:
await _aw.validate_smart_meter(account_number)
@@ -57,36 +81,91 @@ async def validate_credentials(
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anglian Water."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.authenticator: MSOB2CAuth | None = None
self.accounts: list[selector.SelectOptionDict] = []
self.user_input: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
validation_response = await validate_credentials(
MSOB2CAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_create_clientsession(
self.hass,
cookie_jar=CookieJar(quote_cookie=False),
),
self.authenticator = MSOB2CAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_create_clientsession(
self.hass,
cookie_jar=CookieJar(quote_cookie=False),
),
user_input[CONF_ACCOUNT_NUMBER],
)
validation_response = await validate_credentials(self.authenticator)
if isinstance(validation_response, str):
errors["base"] = validation_response
else:
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER],
data={
**user_input,
CONF_ACCESS_TOKEN: validation_response.refresh_token,
},
self.accounts = await get_accounts(self.authenticator)
if len(self.accounts) > 1:
self.user_input = user_input
return await self.async_step_select_account()
account_number = self.accounts[0]["value"]
self.user_input = user_input
return await self.async_step_complete(
{
CONF_ACCOUNT_NUMBER: account_number,
}
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_select_account(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the account selection step."""
errors = {}
if user_input is not None:
if TYPE_CHECKING:
assert self.authenticator
validation_result = await validate_account(
self.authenticator,
user_input[CONF_ACCOUNT_NUMBER],
)
if isinstance(validation_result, str):
errors["base"] = validation_result
else:
return await self.async_step_complete(user_input)
return self.async_show_form(
step_id="select_account",
data_schema=vol.Schema(
{
vol.Required(CONF_ACCOUNT_NUMBER): selector.SelectSelector(
selector.SelectSelectorConfig(
options=self.accounts,
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_complete(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the final configuration step."""
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert self.authenticator
assert self.user_input
config_entry_data = {
**self.user_input,
CONF_ACCOUNT_NUMBER: user_input[CONF_ACCOUNT_NUMBER],
CONF_ACCESS_TOKEN: self.authenticator.refresh_token,
}
return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER],
data=config_entry_data,
)

View File

@@ -10,14 +10,21 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"select_account": {
"data": {
"account_number": "Billing account number"
},
"data_description": {
"account_number": "Select the billing account you wish to use."
},
"description": "Multiple active billing accounts were found with your credentials. Please select the account you wish to use. If this is unexpected, contact Anglian Water to confirm your active accounts."
},
"user": {
"data": {
"account_number": "Billing Account Number",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"account_number": "Your account number found on your latest bill.",
"password": "Your password",
"username": "Username or email used to log in to the Anglian Water website."
},

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
}

View File

@@ -134,7 +134,10 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"scene",
"siren",
"switch",
"text",
"update",

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==65"],
"requirements": ["axis==66"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -80,7 +80,7 @@ class AzureDataExplorerClient:
def test_connection(self) -> None:
"""Test connection, will throw Exception if it cannot connect."""
query = f"{self._table} | take 1"
query = f"['{self._table}'] | take 1"
self.query_client.execute_query(self._database, query)

View File

@@ -45,7 +45,7 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def validate_input(self, data: dict[str, Any]) -> dict[str, Any] | None:
async def validate_input(self, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
@@ -54,36 +54,40 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
await self.hass.async_add_executor_job(client.test_connection)
except KustoAuthenticationError as exp:
_LOGGER.error(exp)
except KustoAuthenticationError as err:
_LOGGER.error("Authentication failed: %s", err)
return {"base": "invalid_auth"}
except KustoServiceError as exp:
_LOGGER.error(exp)
except KustoServiceError as err:
_LOGGER.error("Could not connect: %s", err)
return {"base": "cannot_connect"}
return None
return {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict = {}
if user_input:
errors = await self.validate_input(user_input) # type: ignore[assignment]
errors: dict[str, str] = {}
data_schema = STEP_USER_DATA_SCHEMA
if user_input is not None:
errors = await self.validate_input(user_input)
if not errors:
return self.async_create_entry(
data=user_input,
title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
"https://", ""
),
title=f"{user_input[CONF_ADX_CLUSTER_INGEST_URI].replace('https://', '')} / {user_input[CONF_ADX_DATABASE_NAME]} ({user_input[CONF_ADX_TABLE_NAME]})",
options=DEFAULT_OPTIONS,
)
# Keep previously entered values when we re-show the form after an error.
data_schema = self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
data_schema=data_schema,
errors=errors,
last_step=True,
)

View File

@@ -20,6 +20,7 @@
"use_queued_ingestion": "Use queued ingestion"
},
"data_description": {
"authority_id": "In Azure portal this is also known as Directory (tenant) ID",
"cluster_ingest_uri": "Ingestion URI of the cluster",
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
},

View File

@@ -6,13 +6,15 @@ from datetime import timedelta
import logging
from typing import Any
from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception
from b2sdk.v2 import Bucket, exception
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
# Import from b2_client to ensure timeout configuration is applied
from .b2_client import B2Api, InMemoryAccountInfo
from .const import (
BACKBLAZE_REALM,
CONF_APPLICATION_KEY,
@@ -72,7 +74,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
translation_domain=DOMAIN,
translation_key="invalid_bucket_name",
) from err
except exception.ConnectionReset as err:
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,
exception.ConnectionReset,
) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",

View File

@@ -0,0 +1,39 @@
"""Backblaze B2 client with extended timeouts.
The b2sdk library uses class-level timeout attributes. To avoid modifying
global library state, we subclass the relevant classes to provide extended
timeouts suitable for backup operations involving large files.
"""
from b2sdk.v2 import B2Api as BaseB2Api, InMemoryAccountInfo
from b2sdk.v2.b2http import B2Http as BaseB2Http
from b2sdk.v2.session import B2Session as BaseB2Session
# Extended timeouts for Home Assistant backup operations
# Default CONNECTION_TIMEOUT is 46 seconds, which can be too short for slow connections
CONNECTION_TIMEOUT = 120 # 2 minutes
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
class B2Http(BaseB2Http): # type: ignore[misc]
"""B2Http with extended timeouts for backup operations."""
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
class B2Session(BaseB2Session): # type: ignore[misc]
"""B2Session using custom B2Http with extended timeouts."""
B2HTTP_CLASS = B2Http
class B2Api(BaseB2Api): # type: ignore[misc]
"""B2Api using custom session with extended timeouts."""
SESSION_CLASS = B2Session
__all__ = ["B2Api", "InMemoryAccountInfo"]

View File

@@ -6,7 +6,7 @@ from collections.abc import Mapping
import logging
from typing import Any
from b2sdk.v2 import B2Api, InMemoryAccountInfo, exception
from b2sdk.v2 import exception
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
@@ -17,6 +17,8 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
# Import from b2_client to ensure timeout configuration is applied
from .b2_client import B2Api, InMemoryAccountInfo
from .const import (
BACKBLAZE_REALM,
CONF_APPLICATION_KEY,
@@ -172,8 +174,12 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
)
errors[CONF_BUCKET] = "invalid_bucket_name"
except exception.ConnectionReset:
_LOGGER.error("Failed to connect to Backblaze B2. Connection reset")
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,
exception.ConnectionReset,
) as err:
_LOGGER.error("Failed to connect to Backblaze B2: %s", err)
errors["base"] = "cannot_connect"
except exception.MissingAccountData:
# This generally indicates an issue with how InMemoryAccountInfo is used

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["b2sdk"],
"quality_scale": "bronze",
"requirements": ["b2sdk==2.8.1"]
"requirements": ["b2sdk==2.10.1"]
}

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry
from .const import DOMAIN
from .util import get_device_buttons
from .util import get_device_buttons, get_remote_keys, get_remotes
async def async_get_config_entry_diagnostics(
@@ -53,4 +53,23 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data[f"{device_button}_event"] = state_dict
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_{key_type}",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_{key_type}_event"] = state_dict
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
return data

View File

@@ -16,11 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
@@ -29,7 +25,7 @@ from .const import (
WebsocketNotification,
)
from .entity import BeoEntity
from .util import get_device_buttons, get_remotes
from .util import get_device_buttons, get_remote_keys, get_remotes
PARALLEL_UPDATES = 0
@@ -40,38 +36,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Event entities from config entry."""
entities: list[BeoEvent] = []
async_add_entities(
entities: list[BeoEvent] = [
BeoButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
)
]
# Check for connected Beoremote One
remotes = await get_remotes(config_entry.runtime_data.client)
for remote in remotes:
# Add Light keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
)
for key_type in BEO_REMOTE_KEYS
]
)
# Add Control keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
)
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
BeoRemoteKeyEvent(config_entry, remote, key_type)
for key_type in get_remote_keys()
]
)

View File

@@ -11,7 +11,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
DEVICE_BUTTONS,
DOMAIN,
BeoButtons,
BeoModel,
)
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -64,3 +73,14 @@ def get_device_buttons(model: BeoModel) -> list[str]:
buttons.remove(BeoButtons.BLUETOOTH)
return buttons
def get_remote_keys() -> list[str]:
"""Get remote keys for the Beoremote One. Formatted for Home Assistant use."""
return [
*[f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}" for key_type in BEO_REMOTE_KEYS],
*[
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}"
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]

View File

@@ -2,7 +2,6 @@
"domain": "blackbird",
"name": "Monoprice Blackbird Matrix Switch",
"codeowners": [],
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
"documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling",
"loggers": ["pyblackbird"],

View File

@@ -2,16 +2,25 @@
from pyblu import Player
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import (
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
@@ -28,6 +37,38 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_increase_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_CLEAR_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_clear_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_JOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_MASTER): cv.entity_id},
func="async_bluesound_join",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UNJOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_bluesound_unjoin",
)
return True

View File

@@ -4,3 +4,8 @@ DOMAIN = "bluesound"
INTEGRATION_TITLE = "Bluesound"
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"

View File

@@ -8,7 +8,6 @@ import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -22,11 +21,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -40,9 +35,22 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .const import (
ATTR_BLUESOUND_GROUP,
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .coordinator import BluesoundCoordinator
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
from .utils import (
dispatcher_join_signal,
dispatcher_unjoin_signal,
format_unique_id,
id_to_paired_player,
)
if TYPE_CHECKING:
from . import BluesoundConfigEntry
@@ -54,11 +62,6 @@ SCAN_INTERVAL = timedelta(minutes=15)
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
POLL_TIMEOUT = 120
@@ -75,18 +78,6 @@ async def async_setup_entry(
config_entry.runtime_data.player,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_TIMER, None, "async_increase_timer"
)
platform.async_register_entity_service(
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
)
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
async_add_entities([bluesound_player], update_before_add=True)
@@ -120,6 +111,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._presets: list[Preset] = coordinator.data.presets
self._group_name: str | None = None
self._group_list: list[str] = []
self._group_members: list[str] | None = None
self._bluesound_device_name = sync_status.name
self._player = player
self._last_status_update = dt_util.utcnow()
@@ -180,6 +172,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._last_status_update = dt_util.utcnow()
self._group_list = self.rebuild_bluesound_group()
self._group_members = self.rebuild_group_members()
self.async_write_ha_state()
@@ -365,11 +358,13 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.GROUPING
)
supported = (
MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.GROUPING
)
if not self._status.indexing:
@@ -421,8 +416,57 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return shuffle
async def async_join(self, master: str) -> None:
@property
def group_members(self) -> list[str] | None:
"""Get list of group members. Leader is always first."""
return self._group_members
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
if self.entity_id in group_members:
raise ServiceValidationError("Cannot join player to itself")
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
paired_players = []
for group_member in group_members:
sync_status = entity_ids_with_sync_status.get(group_member)
if sync_status is None:
continue
paired_player = id_to_paired_player(sync_status.id)
if paired_player:
paired_players.append(paired_player)
if paired_players:
await self._player.add_followers(paired_players)
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
if self._sync_status.leader is not None:
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
async_dispatcher_send(
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
)
if self._sync_status.followers is not None:
await self._player.remove_follower(self.host, self.port)
async def async_bluesound_join(self, master: str) -> None:
"""Join the player to a group."""
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_JOIN}",
is_fixable=False,
breaks_in_ha_version="2026.7.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_join",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
if master == self.entity_id:
raise ServiceValidationError("Cannot join player to itself")
@@ -431,18 +475,24 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self.hass, dispatcher_join_signal(master), self.host, self.port
)
async def async_unjoin(self) -> None:
async def async_bluesound_unjoin(self) -> None:
"""Unjoin the player from a group."""
if self._sync_status.leader is None:
return
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
_LOGGER.debug("Trying to unjoin player: %s", self.id)
async_dispatcher_send(
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_UNJOIN}",
is_fixable=False,
breaks_in_ha_version="2026.7.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_unjoin",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
await self.async_unjoin_player()
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""List members in group."""
@@ -488,6 +538,63 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
follower_names.insert(0, leader_sync_status.name)
return follower_names
def rebuild_group_members(self) -> list[str] | None:
"""Get list of group members. Leader is always first."""
if self.sync_status.leader is None and self.sync_status.followers is None:
return None
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
leader_entity_id = None
followers = None
if self.sync_status.followers is not None:
leader_entity_id = self.entity_id
followers = self.sync_status.followers
elif self.sync_status.leader is not None:
leader_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
for entity_id, sync_status in entity_ids_with_sync_status.items():
if sync_status.id == leader_id:
leader_entity_id = entity_id
followers = sync_status.followers
break
if leader_entity_id is None or followers is None:
return None
grouped_entity_ids = [leader_entity_id]
for follower in followers:
follower_id = f"{follower.ip}:{follower.port}"
entity_ids = [
entity_id
for entity_id, sync_status in entity_ids_with_sync_status.items()
if sync_status.id == follower_id
]
match entity_ids:
case [entity_id]:
grouped_entity_ids.append(entity_id)
return grouped_entity_ids
def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]:
result = {}
entity_registry = er.async_get(self.hass)
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
)
for config_entry in config_entries:
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
for entity_entry in entity_entries:
if entity_entry.domain == "media_player":
result[entity_entry.entity_id] = (
config_entry.runtime_data.coordinator.data.sync_status
)
return result
async def async_add_follower(self, host: str, port: int) -> None:
"""Add follower to leader."""
await self._player.add_follower(host, port)

View File

@@ -41,9 +41,17 @@
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.clear_sleep_timer"
},
"deprecated_service_join": {
"description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.join"
},
"deprecated_service_set_sleep_timer": {
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.set_sleep_timer"
},
"deprecated_service_unjoin": {
"description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.unjoin"
}
},
"services": {

View File

@@ -1,5 +1,7 @@
"""Utility functions for the Bluesound component."""
from pyblu import PairedPlayer
from homeassistant.helpers.device_registry import format_mac
@@ -19,3 +21,12 @@ def dispatcher_unjoin_signal(leader_id: str) -> str:
Id is ip_address:port. This can be obtained from sync_status.id.
"""
return f"bluesound_unjoin_{leader_id}"
def id_to_paired_player(id: str) -> PairedPlayer | None:
"""Try to convert id in format 'ip:port' to PairedPlayer. Returns None if unable to do so."""
match id.rsplit(":", 1):
case [str() as ip, str() as port] if port.isdigit():
return PairedPlayer(ip, int(port))
case _:
return None

View File

@@ -27,13 +27,18 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
@@ -49,6 +54,12 @@ class BSBLanData:
static: StaticState
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the BSB-Lan integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Set up BSB-Lan from a config entry."""

View File

@@ -0,0 +1,10 @@
{
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"
},
"sync_time": {
"service": "mdi:timer-sync-outline"
}
}
}

View File

@@ -0,0 +1,291 @@
"""Support for BSB-Lan services."""
from __future__ import annotations
from datetime import time
import logging
from typing import TYPE_CHECKING
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.util import dt as dt_util
from .const import DOMAIN
if TYPE_CHECKING:
from . import BSBLanConfigEntry
LOGGER = logging.getLogger(__name__)
ATTR_DEVICE_ID = "device_id"
ATTR_MONDAY_SLOTS = "monday_slots"
ATTR_TUESDAY_SLOTS = "tuesday_slots"
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
ATTR_THURSDAY_SLOTS = "thursday_slots"
ATTR_FRIDAY_SLOTS = "friday_slots"
ATTR_SATURDAY_SLOTS = "saturday_slots"
ATTR_SUNDAY_SLOTS = "sunday_slots"
# Service names
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
SERVICE_SYNC_TIME = "sync_time"
# Schema for a single time slot
_SLOT_SCHEMA = vol.Schema(
{
vol.Required("start_time"): cv.time,
vol.Required("end_time"): cv.time,
}
)
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
}
)
def _convert_time_slots_to_day_schedule(
slots: list[dict[str, time]] | None,
) -> DaySchedule | None:
"""Convert list of time slot dicts to a DaySchedule object.
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
{"start_time": time(17, 0), "end_time": time(21, 0)}]
becomes: DaySchedule with two TimeSlot objects
None returns None (don't modify this day).
Empty list returns DaySchedule with empty slots (clear this day).
"""
if slots is None:
return None
if not slots:
return DaySchedule(slots=[])
time_slots = []
for slot in slots:
start_time = slot["start_time"]
end_time = slot["end_time"]
# Validate that end time is after start time
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_before_start_time",
translation_placeholders={
"start_time": start_time.strftime("%H:%M"),
"end_time": end_time.strftime("%H:%M"),
},
)
time_slots.append(TimeSlot(start=start_time, end=end_time))
LOGGER.debug(
"Created time slot: %s-%s",
start_time.strftime("%H:%M"),
end_time.strftime("%H:%M"),
)
LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
return DaySchedule(slots=time_slots)
async def set_hot_water_schedule(service_call: ServiceCall) -> None:
"""Set hot water heating schedule."""
device_id = service_call.data[ATTR_DEVICE_ID]
# Get the device and config entry
device_registry = dr.async_get(service_call.hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
# Find the config entry for this device
matching_entries: list[BSBLanConfigEntry] = [
entry
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
]
if not matching_entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry_for_device",
translation_placeholders={"device_id": device_entry.name or device_id},
)
entry = matching_entries[0]
# Verify the config entry is loaded
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
translation_placeholders={"device_name": device_entry.name or device_id},
)
client = entry.runtime_data.client
# Convert time slots to DaySchedule objects
monday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_MONDAY_SLOTS)
)
tuesday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_TUESDAY_SLOTS)
)
wednesday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_WEDNESDAY_SLOTS)
)
thursday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_THURSDAY_SLOTS)
)
friday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_FRIDAY_SLOTS)
)
saturday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_SATURDAY_SLOTS)
)
sunday = _convert_time_slots_to_day_schedule(
service_call.data.get(ATTR_SUNDAY_SLOTS)
)
# Create the DHWSchedule object
dhw_schedule = DHWSchedule(
monday=monday,
tuesday=tuesday,
wednesday=wednesday,
thursday=thursday,
friday=friday,
saturday=saturday,
sunday=sunday,
)
LOGGER.debug(
"Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, "
"Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s",
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday,
)
try:
# Call the BSB-Lan API to set the schedule
await client.set_hot_water_schedule(dhw_schedule)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_schedule_failed",
translation_placeholders={"error": str(err)},
) from err
# Refresh the slow coordinator to get the updated schedule
await entry.runtime_data.slow_coordinator.async_request_refresh()
async def async_sync_time(service_call: ServiceCall) -> None:
"""Synchronize BSB-LAN device time with Home Assistant."""
device_id: str = service_call.data[ATTR_DEVICE_ID]
# Get the device and config entry
device_registry = dr.async_get(service_call.hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
# Find the config entry for this device
matching_entries: list[BSBLanConfigEntry] = [
entry
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
]
if not matching_entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry_for_device",
translation_placeholders={"device_id": device_entry.name or device_id},
)
entry = matching_entries[0]
# Verify the config entry is loaded
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
translation_placeholders={"device_name": device_entry.name or device_id},
)
client = entry.runtime_data.client
try:
# Get current device time
device_time = await client.time()
current_time = dt_util.now()
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
# Only sync if device time differs from HA time
if device_time.time.value != current_time_str:
await client.set_time(current_time_str)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="sync_time_failed",
translation_placeholders={
"device_name": device_entry.name or device_id,
"error": str(err),
},
) from err
SYNC_TIME_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
}
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the BSB-Lan services."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,
set_hot_water_schedule,
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SYNC_TIME,
async_sync_time,
schema=SYNC_TIME_SCHEMA,
)

View File

@@ -0,0 +1,122 @@
sync_time:
fields:
device_id:
required: true
example: "abc123device456"
selector:
device:
integration: bsblan
set_hot_water_schedule:
fields:
device_id:
required: true
example: "abc123device456"
selector:
device:
integration: bsblan
monday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
tuesday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
wednesday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
thursday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
friday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
saturday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:
sunday_slots:
selector:
object:
multiple: true
label_field: start_time
description_field: end_time
fields:
start_time:
required: true
selector:
time:
end_time:
required: true
selector:
time:

View File

@@ -70,6 +70,18 @@
}
},
"exceptions": {
"config_entry_not_loaded": {
"message": "The device `{device_name}` is not currently loaded or available"
},
"end_time_before_start_time": {
"message": "End time ({end_time}) must be after start time ({start_time})"
},
"invalid_device_id": {
"message": "Invalid device ID: {device_id}"
},
"no_config_entry_for_device": {
"message": "No configuration entry found for device: {device_id}"
},
"set_data_error": {
"message": "An error occurred while sending the data to the BSB-Lan device"
},
@@ -79,6 +91,9 @@
"set_preset_mode_error": {
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
},
"set_schedule_failed": {
"message": "Failed to set hot water schedule: {error}"
},
"set_temperature_error": {
"message": "An error occurred while setting the temperature"
},
@@ -90,6 +105,59 @@
},
"setup_general_error": {
"message": "An unknown error occurred while retrieving static device data"
},
"sync_time_failed": {
"message": "Failed to sync time for {device_name}: {error}"
}
},
"services": {
"set_hot_water_schedule": {
"description": "Set the hot water heating schedule for a BSB-LAN device.",
"fields": {
"device_id": {
"description": "The BSB-LAN device to configure.",
"name": "Device"
},
"friday_slots": {
"description": "Time periods for Friday. Add multiple slots for different heating periods throughout the day.",
"name": "Friday time slots"
},
"monday_slots": {
"description": "Time periods for Monday. Add multiple slots for different heating periods throughout the day.",
"name": "Monday time slots"
},
"saturday_slots": {
"description": "Time periods for Saturday. Add multiple slots for different heating periods throughout the day.",
"name": "Saturday time slots"
},
"sunday_slots": {
"description": "Time periods for Sunday. Add multiple slots for different heating periods throughout the day.",
"name": "Sunday time slots"
},
"thursday_slots": {
"description": "Time periods for Thursday. Add multiple slots for different heating periods throughout the day.",
"name": "Thursday time slots"
},
"tuesday_slots": {
"description": "Time periods for Tuesday. Add multiple slots for different heating periods throughout the day.",
"name": "Tuesday time slots"
},
"wednesday_slots": {
"description": "Time periods for Wednesday. Add multiple slots for different heating periods throughout the day.",
"name": "Wednesday time slots"
}
},
"name": "Set hot water schedule"
},
"sync_time": {
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
"fields": {
"device_id": {
"description": "The BSB-LAN device to sync time for.",
"name": "Device"
}
},
"name": "Sync time"
}
}
}

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.15.0"]
"requirements": ["bthome-ble==3.17.0"]
}

View File

@@ -12,6 +12,7 @@
"codeowners": ["@emontnemery"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cast",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.9"],

View File

@@ -98,6 +98,18 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"current_temperature_changed": {
"trigger": "mdi:thermometer"
},
"current_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},
@@ -110,9 +122,18 @@
"started_heating": {
"trigger": "mdi:fire"
},
"target_humidity_changed": {
"trigger": "mdi:water-percent"
},
"target_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"target_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"turned_off": {
"trigger": "mdi:power-off"
},

View File

@@ -204,6 +204,14 @@
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
}
},
"services": {
@@ -304,6 +312,78 @@
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
@@ -348,6 +428,42 @@
},
"name": "Climate-control device started heating"
},
"target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the target humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the target humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device target humidity changed"
},
"target_humidity_crossed_threshold": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target humidity crossed threshold"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": {
@@ -362,6 +478,28 @@
},
"name": "Climate-control device target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {

View File

@@ -11,12 +11,21 @@ from homeassistant.helpers.trigger import (
Trigger,
TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
CONF_HVAC_MODE = "hvac_mode"
@@ -44,6 +53,18 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
@@ -51,9 +72,18 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -33,6 +33,17 @@
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
@@ -54,8 +65,58 @@ hvac_mode_changed:
- unknown
multiple: true
current_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
current_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from .const import DEFAULT_PORT
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -22,6 +22,16 @@ BRIDGE_PLATFORMS = [
Platform.SENSOR,
Platform.SWITCH,
]
BRIDGE_AND_VEDO_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
VEDO_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
@@ -37,15 +47,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
session = await async_client_session(hass)
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
vedo_pin = entry.data.get(CONF_VEDO_PIN)
coordinator = ComelitSerialBridge(
hass,
entry,
entry.data[CONF_HOST],
entry.data.get(CONF_PORT, DEFAULT_PORT),
entry.data[CONF_PIN],
vedo_pin,
session,
)
platforms = BRIDGE_PLATFORMS
# Add VEDO platforms if vedo_pin is configured
if vedo_pin:
platforms = BRIDGE_AND_VEDO_PLATFORMS
else:
coordinator = ComelitVedoSystem(
hass,
@@ -71,6 +86,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
platforms = BRIDGE_PLATFORMS
# Add VEDO platforms if vedo_pin was configured
if entry.data.get(CONF_VEDO_PIN):
platforms = BRIDGE_AND_VEDO_PLATFORMS
else:
platforms = VEDO_PLATFORMS

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import AlarmAreaState
from aiocomelit.const import ALARM_AREA, AlarmAreaState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -56,15 +56,25 @@ async def async_setup_entry(
) -> None:
"""Set up the Comelit VEDO system alarm control panel devices."""
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
coordinator = config_entry.runtime_data
is_bridge = isinstance(coordinator, ComelitSerialBridge)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
)
if TYPE_CHECKING:
if is_bridge:
assert isinstance(coordinator, ComelitSerialBridge)
else:
assert isinstance(coordinator, ComelitVedoSystem)
if data := coordinator.data[ALARM_AREA]:
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in data.values()
)
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
class ComelitAlarmEntity(
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], AlarmControlPanelEntity
):
"""Representation of a Ness alarm panel."""
_attr_has_entity_name = True
@@ -78,7 +88,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
def __init__(
self,
coordinator: ComelitVedoSystem,
coordinator: ComelitVedoSystem | ComelitSerialBridge,
area: ComelitVedoAreaObject,
config_entry_entry_id: str,
) -> None:
@@ -95,7 +105,9 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
@property
def _area(self) -> ComelitVedoAreaObject:
"""Return area object."""
return self.coordinator.data["alarm_areas"][self._area_index]
return cast(
ComelitVedoAreaObject, self.coordinator.data[ALARM_AREA][self._area_index]
)
@property
def available(self) -> bool:

View File

@@ -2,9 +2,10 @@
from __future__ import annotations
from typing import cast
from typing import TYPE_CHECKING, cast
from aiocomelit import ComelitVedoZoneObject
from aiocomelit.api import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -15,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ObjectClassType
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .utils import new_device_listener
# Coordinator is used to centralize the data updates
@@ -29,25 +30,32 @@ async def async_setup_entry(
) -> None:
"""Set up Comelit VEDO presence sensors."""
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
coordinator = config_entry.runtime_data
is_bridge = isinstance(coordinator, ComelitSerialBridge)
if TYPE_CHECKING:
if is_bridge:
assert isinstance(coordinator, ComelitSerialBridge)
else:
assert isinstance(coordinator, ComelitVedoSystem)
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
)
class ComelitVedoBinarySensorEntity(
CoordinatorEntity[ComelitVedoSystem], BinarySensorEntity
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], BinarySensorEntity
):
"""Sensor device."""
@@ -56,7 +64,7 @@ class ComelitVedoBinarySensorEntity(
def __init__(
self,
coordinator: ComelitVedoSystem,
coordinator: ComelitVedoSystem | ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
) -> None:
@@ -68,9 +76,25 @@ class ComelitVedoBinarySensorEntity(
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
@property
def _zone(self) -> ComelitVedoZoneObject:
"""Return zone object."""
return cast(
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
)
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._zone.human_status in [
AlarmZoneState.FAULTY,
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
]:
return False
return super().available
@property
def is_on(self) -> bool:
"""Presence detected."""
return (
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
)
return self._zone.status_api == "0001"

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from asyncio.exceptions import TimeoutError
from collections.abc import Mapping
import re
from typing import Any
from typing import TYPE_CHECKING, Any
from aiocomelit import (
ComeliteSerialBridgeApi,
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
@@ -34,9 +34,12 @@ USER_SCHEMA = vol.Schema(
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_PIN): cv.string, vol.Optional(CONF_VEDO_PIN): cv.string}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
@@ -72,6 +75,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
finally:
await api.logout()
# Validate VEDO PIN if provided and device type is BRIDGE
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
raise InvalidVedoPin
if TYPE_CHECKING:
assert isinstance(api, ComeliteSerialBridgeApi)
# Verify VEDO is enabled with the provided PIN
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
raise InvalidVedoAuth
return {"title": data[CONF_HOST]}
@@ -99,6 +114,10 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -182,6 +201,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
if CONF_VEDO_PIN in user_input:
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
await validate_input(self.hass, data_to_validate)
except CannotConnect:
errors["base"] = "cannot_connect"
@@ -189,6 +210,10 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -198,6 +223,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
if CONF_VEDO_PIN in user_input:
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=data_updates
)
@@ -211,6 +238,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
): cv.port,
vol.Optional(CONF_PIN): cv.string,
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
@@ -231,3 +259,11 @@ class InvalidAuth(HomeAssistantError):
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""
class InvalidVedoPin(HomeAssistantError):
"""Error to indicate an invalid VEDO pin."""
class InvalidVedoAuth(HomeAssistantError):
"""Error to indicate VEDO authentication failed."""

View File

@@ -19,6 +19,7 @@ ObjectClassType = (
DOMAIN = "comelit"
DEFAULT_PORT = 80
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
CONF_VEDO_PIN = "vedo_pin"
SCAN_INTERVAL = 5

View File

@@ -1,17 +1,14 @@
"""Support for Comelit."""
from abc import abstractmethod
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, TypeVar
from typing import TypeVar, cast
from aiocomelit.api import (
AlarmDataObject,
ComelitCommonApi,
ComeliteSerialBridgeApi,
ComelitSerialBridgeObject,
ComelitVedoApi,
)
from aiocomelit.api import ComelitCommonApi, ComeliteSerialBridgeApi, ComelitVedoApi
from aiocomelit.const import (
ALARM_AREA,
ALARM_ZONE,
BRIDGE,
CLIMATE,
COVER,
@@ -37,7 +34,10 @@ type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
T = TypeVar(
"T",
bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
bound=dict[
str,
Mapping[int, ObjectClassType],
],
)
@@ -118,8 +118,8 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
async def _async_remove_stale_devices(
self,
previous_list: dict[int, Any],
current_list: dict[int, Any],
previous_list: Mapping[int, ObjectClassType],
current_list: Mapping[int, ObjectClassType],
dev_type: str,
) -> None:
"""Remove stale devices."""
@@ -143,9 +143,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
)
class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
):
class ComelitSerialBridge(ComelitBaseCoordinator[T]):
"""Queries Comelit Serial Bridge."""
_hw_version = "20003101"
@@ -158,17 +156,23 @@ class ComelitSerialBridge(
host: str,
port: int,
pin: str,
vedo_pin: str | None,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
self.vedo_pin = vedo_pin
super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
) -> T:
"""Specific method for updating data."""
data = await self.api.get_all_devices()
data: dict[
str,
Mapping[int, ObjectClassType],
] = {}
data.update(await self.api.get_all_devices())
if self.data:
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
@@ -176,10 +180,14 @@ class ComelitSerialBridge(
self.data[dev_type], data[dev_type], dev_type
)
return data
# Get VEDO alarm data if vedo_pin is configured
if self.vedo_pin:
data.update(await self.api.get_all_areas_and_zones())
return cast(T, data)
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
class ComelitVedoSystem(ComelitBaseCoordinator[T]):
"""Queries Comelit VEDO system."""
_hw_version = "VEDO IP"
@@ -196,20 +204,21 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
) -> None:
"""Initialize the scanner."""
self.api = ComelitVedoApi(host, port, pin, session)
self.vedo_pin = pin
super().__init__(hass, entry, VEDO, host)
async def _async_update_system_data(
self,
) -> AlarmDataObject:
) -> T:
"""Specific method for updating data."""
data = await self.api.get_all_areas_and_zones()
if self.data:
for obj_type in ("alarm_areas", "alarm_zones"):
for obj_type in (ALARM_AREA, ALARM_ZONE):
await self._async_remove_stale_devices(
self.data[obj_type],
data[obj_type],
"area" if obj_type == "alarm_areas" else "zone",
"area" if obj_type == ALARM_AREA else "zone",
)
return data
return cast(T, data)

View File

@@ -72,7 +72,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
@property
def device_status(self) -> int:
"""Return current device status."""
return self.coordinator.data[COVER][self._device.index].status
return cast("int", self.coordinator.data[COVER][self._device.index].status)
@property
def is_closed(self) -> bool | None:
@@ -86,7 +86,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
@property
def is_closing(self) -> bool:
"""Return if the cover is closing."""
return self._current_action("closing")
return bool(self._current_action("closing"))
@property
def is_opening(self) -> bool:

View File

@@ -68,4 +68,4 @@ class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
@property
def is_on(self) -> bool:
"""Return True if light is on."""
return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON
return bool(self.coordinator.data[LIGHT][self._device.index].status == STATE_ON)

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==1.1.2"]
"requirements": ["aiocomelit==2.0.0"]
}

View File

@@ -2,17 +2,17 @@
from __future__ import annotations
from typing import Final, cast
from typing import TYPE_CHECKING, Final, cast
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from aiocomelit.const import ALARM_ZONE, OTHER, AlarmZoneState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import CONF_TYPE, UnitOfPower
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -52,23 +52,20 @@ async def async_setup_entry(
) -> None:
"""Set up Comelit sensors."""
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
await async_setup_bridge_entry(hass, config_entry, async_add_entities)
else:
await async_setup_vedo_entry(hass, config_entry, async_add_entities)
coordinator = config_entry.runtime_data
is_bridge = isinstance(coordinator, ComelitSerialBridge)
if TYPE_CHECKING:
if is_bridge:
assert isinstance(coordinator, ComelitSerialBridge)
else:
assert isinstance(coordinator, ComelitVedoSystem)
async def async_setup_bridge_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit Bridge sensors."""
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
def _add_new_bridge_entities(
new_devices: list[ObjectClassType], dev_type: str
) -> None:
"""Add entities for new monitors."""
assert isinstance(coordinator, ComelitSerialBridge)
entities = [
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
@@ -80,36 +77,32 @@ async def async_setup_bridge_entry(
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, OTHER)
)
async def async_setup_vedo_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit VEDO sensors."""
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
def _add_new_vedo_entities(
new_devices: list[ObjectClassType], dev_type: str
) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
# Bridge native sensors
if is_bridge:
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_bridge_entities, OTHER)
)
# Alarm sensors (both via Bridge or VedoSystem)
if coordinator.vedo_pin:
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_vedo_entities, ALARM_ZONE)
)
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
@@ -141,14 +134,16 @@ class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
)
class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity):
class ComelitVedoSensorEntity(
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], SensorEntity
):
"""Sensor device."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ComelitVedoSystem,
coordinator: ComelitVedoSystem | ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
description: SensorEntityDescription,
@@ -166,7 +161,9 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
@property
def _zone_object(self) -> ComelitVedoZoneObject:
"""Zone object."""
return self.coordinator.data["alarm_zones"][self._zone_index]
return cast(
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
)
@property
def available(self) -> bool:

View File

@@ -5,6 +5,8 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
@@ -13,28 +15,34 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{host}",
"step": {
"reauth_confirm": {
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
"pin": "[%key:common::config_flow::data::pin%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
},
"data_description": {
"pin": "The PIN of your Comelit device."
"pin": "The PIN of your Comelit device.",
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
}
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"pin": "[%key:common::config_flow::data::pin%]",
"port": "[%key:common::config_flow::data::port%]"
"port": "[%key:common::config_flow::data::port%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
},
"data_description": {
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"port": "[%key:component::comelit::config::step::user::data_description::port%]"
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
}
},
"user": {
@@ -42,13 +50,15 @@
"host": "[%key:common::config_flow::data::host%]",
"pin": "[%key:common::config_flow::data::pin%]",
"port": "[%key:common::config_flow::data::port%]",
"type": "Device type"
"type": "Device type",
"vedo_pin": "VEDO alarm PIN (optional)"
},
"data_description": {
"host": "The hostname or IP address of your Comelit device.",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"port": "The port of your Comelit device.",
"type": "The type of your Comelit device."
"type": "The type of your Comelit device.",
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
}
}
}

View File

@@ -82,7 +82,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return True if switch is on."""
return (
return bool(
self.coordinator.data[self._device.type][self._device.index].status
== STATE_ON
)

View File

@@ -66,6 +66,7 @@ async def async_setup_entry(
name="light",
update_method=async_update_data_non_dimmer,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
@@ -73,6 +74,7 @@ async def async_setup_entry(
name="light",
update_method=async_update_data_dimmer,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe

View File

@@ -110,6 +110,7 @@ async def async_setup_entry(
name="room",
update_method=async_update_data,
update_interval=timedelta(seconds=scan_interval),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
}

View File

@@ -14,7 +14,12 @@ from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
from .helpers import cookidoo_from_config_entry
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO]
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CALENDAR,
Platform.SENSOR,
Platform.TODO,
]
_LOGGER = logging.getLogger(__name__)

View File

@@ -0,0 +1,103 @@
"""Calendar platform for the Cookidoo integration."""
from __future__ import annotations
from datetime import date, datetime, timedelta
import logging
from cookidoo_api import CookidooAuthException, CookidooException
from cookidoo_api.types import CookidooCalendarDayRecipe
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
from .entity import CookidooBaseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CookidooConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the calendar platform for entity."""
coordinator = config_entry.runtime_data
async_add_entities([CookidooCalendarEntity(coordinator)])
def recipe_to_event(day_date: date, recipe: CookidooCalendarDayRecipe) -> CalendarEvent:
"""Convert a Cookidoo recipe to a CalendarEvent."""
return CalendarEvent(
start=day_date,
end=day_date + timedelta(days=1), # All-day event
summary=recipe.name,
description=f"Total Time: {recipe.total_time}",
)
class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
"""A calendar entity."""
_attr_translation_key = "meal_plan"
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
assert coordinator.config_entry.unique_id
self._attr_unique_id = coordinator.config_entry.unique_id
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
if not self.coordinator.data.week_plan:
return None
today = date.today()
for day_data in self.coordinator.data.week_plan:
day_date = date.fromisoformat(day_data.id)
if day_date >= today and day_data.recipes:
recipe = day_data.recipes[0]
return recipe_to_event(day_date, recipe)
return None
async def _fetch_week_plan(self, week_day: date) -> list:
"""Fetch a single Cookidoo week plan, retrying once on auth failure."""
try:
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
week_day
)
except CookidooAuthException:
await self.coordinator.cookidoo.refresh_token()
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
week_day
)
except CookidooException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="calendar_fetch_failed",
) from e
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
events: list[CalendarEvent] = []
current_day = start_date.date()
while current_day <= end_date.date():
week_plan = await self._fetch_week_plan(current_day)
for day_data in week_plan:
day_date = date.fromisoformat(day_data.id)
if start_date.date() <= day_date <= end_date.date():
events.extend(
recipe_to_event(day_date, recipe) for recipe in day_data.recipes
)
current_day += timedelta(days=7) # Move to the next week
return events

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from datetime import date, timedelta
import logging
from cookidoo_api import (
@@ -16,6 +16,7 @@ from cookidoo_api import (
CookidooSubscription,
CookidooUserInfo,
)
from cookidoo_api.types import CookidooCalendarDay
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL
@@ -37,6 +38,7 @@ class CookidooData:
ingredient_items: list[CookidooIngredientItem]
additional_items: list[CookidooAdditionalItem]
subscription: CookidooSubscription | None
week_plan: list[CookidooCalendarDay]
class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
@@ -81,6 +83,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
subscription = await self.cookidoo.get_active_subscription()
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today())
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
@@ -106,4 +109,5 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
ingredient_items=ingredient_items,
additional_items=additional_items,
subscription=subscription,
week_plan=week_plan,
)

View File

@@ -54,6 +54,11 @@
"name": "Clear shopping list and additional purchases"
}
},
"calendar": {
"meal_plan": {
"name": "Meal plan"
}
},
"sensor": {
"expires": {
"name": "Subscription expiration date"
@@ -80,6 +85,9 @@
"button_clear_todo_failed": {
"message": "Failed to clear all items from the Cookidoo shopping list"
},
"calendar_fetch_failed": {
"message": "Failed to fetch Cookidoo meal plan"
},
"setup_authentication_exception": {
"message": "Authentication failed for {email}, check your email and password"
},

View File

@@ -70,6 +70,7 @@ MEDIA_MODES = {
"Favorites": "FAVORITES",
"Internet Radio": "IRADIO",
"USB/IPOD": "USB/IPOD",
"USB": "USB",
}
# Sub-modes of 'NET/USB'
@@ -279,7 +280,7 @@ class DenonDevice(MediaPlayerEntity):
def mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player."""
mute_status = "ON" if mute else "OFF"
self.telnet_command(f"MU{mute_status})")
self.telnet_command(f"MU{mute_status}")
def media_play(self) -> None:
"""Play media player."""

View File

@@ -8,25 +8,16 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.typing import ConfigType
from .const import ATTR_CONFIG_ENTRY
from .const import DOMAIN
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
from .helpers import update_duckdns
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
ATTR_TXT = "txt"
DOMAIN = "duckdns"
SERVICE_SET_TXT = "set_txt"
CONFIG_SCHEMA = vol.Schema(
{
@@ -40,27 +31,11 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_TXT_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the DuckDNS component."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_TXT,
update_domain_service,
schema=SERVICE_TXT_SCHEMA,
)
async_setup_services(hass)
if DOMAIN not in config:
return True
@@ -87,49 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> b
return True
def get_config_entry(
hass: HomeAssistant, entry_id: str | None = None
) -> DuckDnsConfigEntry:
"""Return config entry or raise if not found or not loaded."""
if entry_id is None:
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if len(config_entries) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_selected",
)
return config_entries[0]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
return entry
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
session = async_get_clientsession(call.hass)
await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@@ -5,3 +5,5 @@ from typing import Final
DOMAIN = "duckdns"
ATTR_CONFIG_ENTRY: Final = "config_entry_id"
ATTR_TXT: Final = "txt"
SERVICE_SET_TXT = "set_txt"

View File

@@ -0,0 +1,70 @@
"""Actions for Duck DNS."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT
from .coordinator import DuckDnsConfigEntry
from .helpers import update_duckdns
SERVICE_TXT_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
}
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Habitica integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_TXT,
update_domain_service,
schema=SERVICE_TXT_SCHEMA,
)
def get_config_entry(
hass: HomeAssistant, entry_id: str | None = None
) -> DuckDnsConfigEntry:
"""Return config entry or raise if not found or not loaded."""
if entry_id is None:
if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_selected",
)
return entries[0]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
return entry
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
session = async_get_clientsession(call.hass)
await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)

View File

@@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
config_panel_domain=DOMAIN,
webcomponent_name="dynalite-panel",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -35,7 +35,7 @@
"cpu_overheating": "CPU overheating",
"none": "None",
"pellets": "Pellets",
"unkownn": "Unknown alarm"
"unknown": "Unknown alarm"
}
},
"convector_air_flow": {

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import AsyncIterable
from io import BytesIO
import logging
from typing import Any
from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
@@ -180,15 +181,17 @@ class ElevenLabsSTTEntity(SpeechToTextEntity):
)
try:
response = await self._client.speech_to_text.convert(
file=BytesIO(audio),
file_format=file_format,
model_id=self._stt_model,
language_code=lang_code,
tag_audio_events=False,
num_speakers=1,
diarize=False,
)
kwargs: dict[str, Any] = {
"file": BytesIO(audio),
"file_format": file_format,
"model_id": self._stt_model,
"tag_audio_events": False,
"num_speakers": 1,
"diarize": False,
}
if lang_code is not None:
kwargs["language_code"] = lang_code
response = await self._client.speech_to_text.convert(**kwargs)
except ApiError as exc:
_LOGGER.error("Error during processing of STT request: %s", exc)
return stt.SpeechResult(None, SpeechResultState.ERROR)

View File

@@ -620,6 +620,7 @@ ENCHARGE_INVENTORY_SENSORS = (
EnvoyEnchargeSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=attrgetter("temperature"),
),
@@ -634,6 +635,7 @@ ENCHARGE_INVENTORY_SENSORS = (
ENCHARGE_POWER_SENSORS = (
EnvoyEnchargePowerSensorEntityDescription(
key="soc",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("soc"),
@@ -641,12 +643,14 @@ ENCHARGE_POWER_SENSORS = (
EnvoyEnchargePowerSensorEntityDescription(
key="apparent_power_mva",
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.APPARENT_POWER,
value_fn=lambda encharge: encharge.apparent_power_mva * 0.001,
),
EnvoyEnchargePowerSensorEntityDescription(
key="real_power_mw",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
value_fn=lambda encharge: encharge.real_power_mw * 0.001,
),
@@ -664,6 +668,7 @@ ENPOWER_SENSORS = (
EnvoyEnpowerSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=attrgetter("temperature"),
),
@@ -693,6 +698,7 @@ COLLAR_SENSORS = (
EnvoyCollarSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=attrgetter("temperature"),
),
@@ -760,6 +766,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
EnvoyEnchargeAggregateSensorEntityDescription(
key="battery_level",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("state_of_charge"),
),
@@ -767,6 +774,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
key="reserve_soc",
translation_key="reserve_soc",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("reserve_state_of_charge"),
),
@@ -774,6 +782,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
key="available_energy",
translation_key="available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY,
value_fn=attrgetter("available_energy"),
),
@@ -781,6 +790,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
key="reserve_energy",
translation_key="reserve_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY,
value_fn=attrgetter("backup_reserve"),
),
@@ -805,12 +815,14 @@ ACB_BATTERY_POWER_SENSORS = (
EnvoyAcbBatterySensorEntityDescription(
key="acb_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
value_fn=attrgetter("power"),
),
EnvoyAcbBatterySensorEntityDescription(
key="acb_soc",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("state_of_charge"),
),
@@ -828,6 +840,7 @@ ACB_BATTERY_ENERGY_SENSORS = (
key="acb_available_energy",
translation_key="acb_available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("charge_wh"),
),
@@ -845,6 +858,7 @@ AGGREGATE_BATTERY_SENSORS = (
EnvoyAggregateBatterySensorEntityDescription(
key="aggregated_soc",
translation_key="aggregated_soc",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("state_of_charge"),
@@ -853,6 +867,7 @@ AGGREGATE_BATTERY_SENSORS = (
key="aggregated_available_energy",
translation_key="aggregated_available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("available_energy"),
),

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==43.3.0",
"aioesphomeapi==43.9.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -8,8 +8,7 @@ import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.core import HomeAssistant
from .manager import async_replace_device
@@ -22,13 +21,6 @@ class ESPHomeRepair(RepairsFlow):
self._data = data
super().__init__()
@callback
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
assert issue is not None
return issue.translation_placeholders or {}
class DeviceConflictRepair(ESPHomeRepair):
"""Handler for an issue fixing device conflict."""
@@ -58,7 +50,6 @@ class DeviceConflictRepair(ESPHomeRepair):
return self.async_show_menu(
step_id="init",
menu_options=["migrate", "manual"],
description_placeholders=self._async_get_placeholders(),
)
async def async_step_migrate(
@@ -69,7 +60,6 @@ class DeviceConflictRepair(ESPHomeRepair):
return self.async_show_form(
step_id="migrate",
data_schema=vol.Schema({}),
description_placeholders=self._async_get_placeholders(),
)
entry_id = self.entry_id
await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac)
@@ -84,7 +74,6 @@ class DeviceConflictRepair(ESPHomeRepair):
return self.async_show_form(
step_id="manual",
data_schema=vol.Schema({}),
description_placeholders=self._async_get_placeholders(),
)
self.hass.config_entries.async_schedule_reload(self.entry_id)
return self.async_create_entry(data={})

View File

@@ -9,14 +9,12 @@ from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData
CONF_URLS = "urls"
MY_KEY: HassKey[StoredData] = HassKey(DOMAIN)
FEEDREADER_KEY: HassKey[StoredData] = HassKey(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool:
"""Set up Feedreader from a config entry."""
storage = hass.data.setdefault(MY_KEY, StoredData(hass))
storage = hass.data.setdefault(FEEDREADER_KEY, StoredData(hass))
if not storage.is_initialized:
await storage.async_setup()
@@ -42,5 +40,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry)
)
# if this is the last entry, remove the storage
if len(entries) == 1:
hass.data.pop(MY_KEY)
hass.data.pop(FEEDREADER_KEY)
return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT])

View File

@@ -19,6 +19,9 @@ from .coordinator import FeedReaderCoordinator
LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
ATTR_CONTENT = "content"
ATTR_DESCRIPTION = "description"
ATTR_LINK = "link"
@@ -42,16 +45,15 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
_attr_event_types = [EVENT_FEEDREADER]
_attr_name = None
_attr_has_entity_name = True
_attr_translation_key = "latest_feed"
_unrecorded_attributes = frozenset(
{ATTR_CONTENT, ATTR_DESCRIPTION, ATTR_TITLE, ATTR_LINK}
)
coordinator: FeedReaderCoordinator
def __init__(self, coordinator: FeedReaderCoordinator) -> None:
"""Initialize the feedreader event."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_latest_feed"
self._attr_translation_key = "latest_feed"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name=coordinator.config_entry.title,

View File

@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: missing test for uniqueness of feed URL.
config-flow:
status: todo
comment: missing data descriptions
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication support.
test-coverage:
status: done
comment: Can use freezer for skipping time instead
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No discovery support.
discovery:
status: exempt
comment: No discovery support.
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Each config entry, represents one service.
entity-category: done
entity-device-class:
status: exempt
comment: Matches no available event entity class.
entity-disabled-by-default:
status: exempt
comment: Only one entity per config entry.
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: done
comment: Only one repair-issue for yaml-import defined.
stale-devices:
status: exempt
comment: Each config entry, represents one service.
# Platinum
async-dependency:
status: todo
comment: feedparser lib is not async.
inject-websession:
status: todo
comment: feedparser lib doesn't take a session as argument.
strict-typing:
status: todo
comment: feedparser lib is not fully typed.

View File

@@ -21,12 +21,6 @@
}
}
},
"issues": {
"import_yaml_error_url_error": {
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
"title": "The Feedreader YAML configuration import failed"
}
},
"options": {
"step": {
"init": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.8"]
"requirements": ["pyfirefly==0.1.10"]
}

View File

@@ -0,0 +1,51 @@
"""The Fish Audio integration."""
from __future__ import annotations
import logging
from fishaudio import AsyncFishAudio
from fishaudio.exceptions import AuthenticationError, FishAudioError
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import CONF_API_KEY
from .types import FishAudioConfigEntry
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.TTS]
async def async_setup_entry(hass: HomeAssistant, entry: FishAudioConfigEntry) -> bool:
"""Set up Fish Audio from a config entry."""
client = AsyncFishAudio(api_key=entry.data[CONF_API_KEY])
try:
# Validate API key by getting account credits.
await client.account.get_credits()
except AuthenticationError as exc:
raise ConfigEntryAuthFailed(f"Invalid API key: {exc}") from exc
except FishAudioError as exc:
raise ConfigEntryNotReady(f"Error connecting to Fish Audio: {exc}") from exc
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(
hass: HomeAssistant, entry: FishAudioConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: FishAudioConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,352 @@
"""Config flow for the Fish Audio integration."""
from __future__ import annotations
import logging
from typing import Any
from fishaudio import AsyncFishAudio
from fishaudio.exceptions import AuthenticationError, FishAudioError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.selector import (
LanguageSelector,
LanguageSelectorConfig,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
API_KEYS_URL,
BACKEND_MODELS,
CONF_API_KEY,
CONF_BACKEND,
CONF_LANGUAGE,
CONF_LATENCY,
CONF_NAME,
CONF_SELF_ONLY,
CONF_SORT_BY,
CONF_TITLE,
CONF_USER_ID,
CONF_VOICE_ID,
DOMAIN,
LATENCY_OPTIONS,
SIGNUP_URL,
SORT_BY_OPTIONS,
TTS_SUPPORTED_LANGUAGES,
)
from .error import (
CannotConnectError,
CannotGetModelsError,
InvalidAuthError,
UnexpectedError,
)
_LOGGER = logging.getLogger(__name__)
def get_api_key_schema(default: str | None = None) -> vol.Schema:
"""Return the schema for API key input."""
return vol.Schema(
{vol.Required(CONF_API_KEY, default=default or vol.UNDEFINED): str}
)
def get_filter_schema(options: dict[str, Any]) -> vol.Schema:
"""Return the schema for the filter step."""
return vol.Schema(
{
vol.Optional(CONF_TITLE, default=options.get(CONF_TITLE, "")): str,
vol.Optional(
CONF_LANGUAGE, default=options.get(CONF_LANGUAGE, "Any")
): LanguageSelector(
LanguageSelectorConfig(
languages=TTS_SUPPORTED_LANGUAGES,
)
),
vol.Optional(
CONF_SORT_BY, default=options.get(CONF_SORT_BY, "task_count")
): SelectSelector(
SelectSelectorConfig(
options=SORT_BY_OPTIONS,
mode=SelectSelectorMode.DROPDOWN,
translation_key="sort_by",
)
),
vol.Optional(
CONF_SELF_ONLY, default=options.get(CONF_SELF_ONLY, False)
): bool,
}
)
def get_model_selection_schema(
options: dict[str, Any],
model_options: list[SelectOptionDict],
) -> vol.Schema:
"""Return the schema for the model selection step."""
return vol.Schema(
{
vol.Required(
CONF_VOICE_ID,
default=options.get(CONF_VOICE_ID, ""),
): SelectSelector(
SelectSelectorConfig(
options=model_options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
vol.Required(
CONF_BACKEND,
default=options.get(CONF_BACKEND, "s1"),
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=opt, label=opt) for opt in BACKEND_MODELS
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
CONF_LATENCY,
default=options.get(CONF_LATENCY, "balanced"),
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=opt, label=opt)
for opt in LATENCY_OPTIONS
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
CONF_NAME,
default=options.get(CONF_NAME) or vol.UNDEFINED,
): str,
}
)
async def _validate_api_key(
hass: HomeAssistant, api_key: str
) -> tuple[str, AsyncFishAudio]:
"""Validate the user input allows us to connect."""
client = AsyncFishAudio(api_key=api_key)
try:
# Validate API key and get user info
credit_info = await client.account.get_credits()
user_id = credit_info.user_id
except AuthenticationError as exc:
raise InvalidAuthError(exc) from exc
except FishAudioError as exc:
raise CannotConnectError(exc) from exc
except Exception as exc:
raise UnexpectedError(exc) from exc
return user_id, client
class FishAudioConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fish Audio."""
VERSION = 1
client: AsyncFishAudio | None
def __init__(self) -> None:
"""Initialize the config flow."""
self.client = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=get_api_key_schema(),
errors={},
description_placeholders={"signup_url": SIGNUP_URL},
)
errors: dict[str, str] = {}
try:
user_id, self.client = await _validate_api_key(
self.hass, user_input[CONF_API_KEY]
)
except InvalidAuthError:
errors["base"] = "invalid_auth"
except CannotConnectError:
errors["base"] = "cannot_connect"
except UnexpectedError:
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
data: dict[str, Any] = {
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_USER_ID: user_id,
}
return self.async_create_entry(
title="Fish Audio",
data=data,
)
return self.async_show_form(
step_id="user",
data_schema=get_api_key_schema(),
errors=errors,
description_placeholders={
"signup_url": SIGNUP_URL,
"api_keys_url": API_KEYS_URL,
},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type]:
"""Return subentries supported by this integration."""
return {"tts": FishAudioSubentryFlowHandler}
class FishAudioSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding and modifying a tts entity."""
config_data: dict[str, Any]
models: list[SelectOptionDict]
client: AsyncFishAudio
def __init__(self) -> None:
"""Initialize the subentry flow handler."""
super().__init__()
self.models: list[SelectOptionDict] = []
async def _async_get_models(
self, self_only: bool, language: str | None, title: str | None, sort_by: str
) -> list[SelectOptionDict]:
"""Get the available models."""
try:
voices_response = await self.client.voices.list(
self_only=self_only,
language=language
if language and language.strip() and language != "Any"
else None,
title=title if title and title.strip() else None,
sort_by=sort_by,
)
except Exception as exc:
raise CannotGetModelsError(exc) from exc
voices = voices_response.items
return [
SelectOptionDict(
value=voice.id,
label=f"{voice.title} - {voice.task_count} uses",
)
for voice in voices
]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the initial step."""
self.config_data = {}
return await self.async_step_init()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of a subentry."""
self.config_data = dict(self._get_reconfigure_subentry().data)
return await self.async_step_init()
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage initial options."""
entry = self._get_entry()
if entry.state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
self.client = entry.runtime_data
if user_input is not None:
self.config_data.update(user_input)
return await self.async_step_model()
return self.async_show_form(
step_id="init",
data_schema=get_filter_schema(self.config_data),
errors={},
)
async def async_step_model(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the model selection step."""
errors: dict[str, str] = {}
if not self.models:
try:
self.models = await self._async_get_models(
self_only=self.config_data.get(CONF_SELF_ONLY, False),
language=self.config_data.get(CONF_LANGUAGE),
title=self.config_data.get(CONF_TITLE),
sort_by=self.config_data.get(CONF_SORT_BY, "task_count"),
)
except CannotGetModelsError:
return self.async_abort(reason="cannot_connect")
if not self.models:
return self.async_abort(reason="no_models_found")
if CONF_VOICE_ID not in self.config_data and self.models:
self.config_data[CONF_VOICE_ID] = self.models[0]["value"]
if user_input is not None:
if (
(voice_id := user_input.get(CONF_VOICE_ID))
and (backend := user_input.get(CONF_BACKEND))
and (name := user_input.get(CONF_NAME))
):
self.config_data.update(user_input)
unique_id = f"{voice_id}-{backend}"
if self.source == SOURCE_USER:
return self.async_create_entry(
title=name,
data=self.config_data,
unique_id=unique_id,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=self.config_data,
unique_id=unique_id,
)
return self.async_show_form(
step_id="model",
data_schema=get_model_selection_schema(self.config_data, self.models),
errors=errors,
)

View File

@@ -0,0 +1,40 @@
"""Constants for the FishAudio integration."""
from typing import Literal
DOMAIN = "fish_audio"
CONF_NAME: Literal["name"] = "name"
CONF_USER_ID: Literal["user_id"] = "user_id"
CONF_API_KEY: Literal["api_key"] = "api_key"
CONF_VOICE_ID: Literal["voice_id"] = "voice_id"
CONF_BACKEND: Literal["backend"] = "backend"
CONF_SELF_ONLY: Literal["self_only"] = "self_only"
CONF_LANGUAGE: Literal["language"] = "language"
CONF_SORT_BY: Literal["sort_by"] = "sort_by"
CONF_LATENCY: Literal["latency"] = "latency"
CONF_TITLE: Literal["title"] = "title"
DEVELOPER_ID = "1e9f9baadce144f5b16dd94cbc0314c8"
TTS_SUPPORTED_LANGUAGES = [
"Any",
"en",
"zh",
"de",
"ja",
"ar",
"fr",
"es",
"ko",
]
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
LATENCY_OPTIONS = ["normal", "balanced"]
SIGNUP_URL = "https://fish.audio/?fpr=homeassistant" # codespell:ignore fpr
BILLING_URL = "https://fish.audio/app/billing/"
API_KEYS_URL = "https://fish.audio/app/api-keys/"

View File

@@ -0,0 +1,52 @@
"""Exceptions for the Fish Audio integration."""
import logging
from homeassistant.exceptions import HomeAssistantError
_LOGGER = logging.getLogger(__package__)
class FishAudioError(HomeAssistantError):
"""Base class for Fish Audio errors."""
class CannotConnectError(FishAudioError):
"""Error to indicate we cannot connect."""
def __init__(self, exc: Exception) -> None:
"""Initialize the connection error."""
super().__init__("Cannot connect")
class InvalidAuthError(FishAudioError):
"""Error to indicate invalid authentication."""
def __init__(self, exc: Exception) -> None:
"""Initialize the invalid auth error."""
super().__init__("Invalid authentication")
class CannotGetModelsError(FishAudioError):
"""Error to indicate we cannot get models."""
def __init__(self, exc: Exception) -> None:
"""Initialize the model fetch error."""
super().__init__("Cannot get models")
class UnexpectedError(FishAudioError):
"""Error to indicate an unexpected error."""
def __init__(self, exc: Exception) -> None:
"""Initialize and log the unexpected error."""
super().__init__("Unexpected error")
_LOGGER.exception("Unexpected exception: %s", exc)
class AlreadyConfiguredError(FishAudioError):
"""Error to indicate already configured."""
def __init__(self, exc: Exception) -> None:
"""Initialize the already configured error."""
super().__init__("Already configured")

View File

@@ -0,0 +1,12 @@
{
"domain": "fish_audio",
"name": "Fish Audio",
"codeowners": ["@noambav"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fish_audio",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["fish_audio_sdk"],
"quality_scale": "bronze",
"requirements": ["fish-audio-sdk==1.1.0"]
}

View File

@@ -0,0 +1,78 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities in this integration do not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
config-entry-unloading: done
log-when-unavailable: todo
entity-unavailable:
status: exempt
comment: TTS platform has no state to mark unavailable.
action-exceptions: done
reauthentication-flow: todo
parallel-updates: done
test-coverage: todo
integration-owner: done
docs-installation-parameters: todo
docs-configuration-parameters: todo
# Gold
entity-translations: todo
entity-device-class:
status: exempt
comment: No device class for TTS entities.
devices: done
entity-category: done
entity-disabled-by-default: todo
discovery:
status: exempt
comment: No physical device to discover.
stale-devices:
status: exempt
comment: No physical device.
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow:
status: todo
comment: Could be useful if default voice disappears.
dynamic-devices:
status: exempt
comment: No physical device.
discovery-update-info:
status: exempt
comment: No physical device.
repair-issues: todo
docs-use-cases: done
docs-supported-devices:
status: exempt
comment: This integration does not support devices.
docs-supported-functions: todo
docs-data-update: todo
docs-known-limitations: todo
docs-troubleshooting: todo
docs-examples: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,91 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "Failed to connect, please check your API key and network connection.",
"invalid_auth": "Invalid authentication. Please check your API key. You can get your API key from [Fish Audio API Keys]({api_keys_url}).",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your personal API key for accessing the Fish Audio service."
},
"description": "Enter your Fish Audio API key to begin.\n\nIf you don't have an account, you can sign up for a free one on [Fish Audio]({signup_url}).",
"title": "Connect to Fish Audio"
}
}
},
"config_subentries": {
"tts": {
"abort": {
"already_configured": "This TTS voice is already configured.",
"cannot_connect": "Failed to connect to Fish Audio",
"entry_not_loaded": "Cannot add TTS voice while the configuration is disabled.",
"no_models_found": "No voices found matching the specified filters. Please adjust your filters and try again.",
"reconfigure_successful": "Your TTS voice has been updated successfully. The integration will now reload with the new settings."
},
"entry_type": "TTS voice",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_model_selected": "You must select a voice to continue.",
"no_models_found": "No voices found matching the specified filters."
},
"initiate_flow": {
"reconfigure": "Reconfigure TTS voice",
"user": "Add TTS voice"
},
"step": {
"init": {
"data": {
"language": "Filter by language",
"self_only": "Show only my private voices",
"sort_by": "Sort voices by",
"title": "Filter by name"
},
"data_description": {
"language": "Display only voices that support the selected language.",
"self_only": "When checked, this will only show the voices you have personally created or cloned.",
"sort_by": "Choose the order in which the voices are displayed.",
"title": "Filter voices by name."
},
"description": "Apply filters to narrow down the voice list, then click Submit to see the results.",
"title": "Voice selection filters"
},
"model": {
"data": {
"backend": "AI voice model",
"latency": "Latency mode",
"name": "[%key:common::config_flow::data::name%]",
"voice_id": "Voice"
},
"data_description": {
"backend": "Select the AI model that will generate the audio.",
"latency": "Choose the latency mode: 'normal' for standard processing or 'balanced' for optimized speed.",
"name": "Enter a unique name for this TTS voice to easily identify it in Home Assistant.",
"voice_id": "Choose from the list of available voices, or manually enter a specific voice ID."
},
"description": "Select your preferred voice and the AI model to use for speech synthesis.",
"title": "Choose your voice and model"
}
}
}
},
"selector": {
"sort_by": {
"options": {
"created_at": "Newest",
"score": "Highest score",
"task_count": "Most uses"
}
}
}
}

View File

@@ -0,0 +1,122 @@
"""TTS platform for the Fish Audio integration."""
from __future__ import annotations
import logging
from typing import Any
from fishaudio.exceptions import APIError, RateLimitError
from homeassistant.components.tts import TextToSpeechEntity, TtsAudioType
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FishAudioConfigEntry
from .const import (
CONF_BACKEND,
CONF_LATENCY,
CONF_VOICE_ID,
DOMAIN,
TTS_SUPPORTED_LANGUAGES,
)
from .error import UnexpectedError
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: FishAudioConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fish Audio TTS platform."""
_LOGGER.debug("Setting up Fish Audio TTS platform")
_LOGGER.debug("Entry: %s", entry)
# Iterate over values
for subentry in entry.subentries.values():
_LOGGER.debug("Subentry: %s", subentry)
if subentry.subentry_type != "tts":
continue
async_add_entities(
[FishAudioTTSEntity(entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class FishAudioTTSEntity(TextToSpeechEntity):
"""Fish Audio TTS entity."""
_attr_has_entity_name = True
_attr_supported_options = [CONF_VOICE_ID, CONF_BACKEND, CONF_LATENCY]
def __init__(self, entry: FishAudioConfigEntry, sub_entry: ConfigSubentry) -> None:
"""Initialize the TTS entity."""
self.client = entry.runtime_data
self.sub_entry = sub_entry
self._attr_unique_id = sub_entry.subentry_id
title = sub_entry.title
backend = sub_entry.data[CONF_BACKEND]
self._attr_name = title
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sub_entry.subentry_id)},
manufacturer="Fish Audio",
model=backend,
name=title,
entry_type=DeviceEntryType.SERVICE,
)
@property
def default_language(self) -> str:
"""Return the default language."""
return "en"
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
return TTS_SUPPORTED_LANGUAGES
async def async_get_tts_audio(
self,
message: str,
language: str,
options: dict[str, Any],
) -> TtsAudioType:
"""Load tts audio file from engine."""
_LOGGER.debug("Getting TTS audio for %s", message)
voice_id = options.get(CONF_VOICE_ID, self.sub_entry.data.get(CONF_VOICE_ID))
backend = options.get(CONF_BACKEND, self.sub_entry.data.get(CONF_BACKEND))
latency = options.get(
CONF_LATENCY, self.sub_entry.data.get(CONF_LATENCY, "balanced")
)
if voice_id is None:
raise ServiceValidationError("Voice ID not configured")
if backend is None:
raise ServiceValidationError("Backend model not configured")
try:
audio = await self.client.tts.convert(
text=message,
reference_id=voice_id,
latency=latency,
model=backend,
format="mp3",
)
except RateLimitError as err:
_LOGGER.error("Fish Audio TTS rate limited: %s", err)
raise HomeAssistantError(f"Rate limited: {err}") from err
except APIError as err:
_LOGGER.error("Fish Audio TTS request failed: %s", err)
raise HomeAssistantError(f"TTS request failed: {err}") from err
except Exception as err:
raise UnexpectedError(err) from err
return "mp3", audio

View File

@@ -0,0 +1,9 @@
"""Type definitions for the Fish Audio integration."""
from __future__ import annotations
from fishaudio import AsyncFishAudio
from homeassistant.config_entries import ConfigEntry
type FishAudioConfigEntry = ConfigEntry[AsyncFishAudio]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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