Compare commits

...

158 Commits

Author SHA1 Message Date
Franck Nijhof
fc8f8b39b4 2025.11.3 (#157006) 2025-11-21 18:02:26 +01:00
Franck Nijhof
ec0918027e Bump version to 2025.11.3 2025-11-21 16:27:45 +00:00
Joost Lekkerkerker
8a54f8d4e2 Throttle Decora wifi updates (#156994) 2025-11-21 16:26:49 +00:00
Bram Kragten
5c27126b6d Update frontend to 20251105.1 (#156992) 2025-11-21 16:26:47 +00:00
Robert Resch
e069aff0e2 Bump go2rtc to 1.9.12 and go2rtc-client to 0.3.0 (#156948) 2025-11-21 16:26:46 +00:00
Timothy
733526fae3 Rework CloudhookURL setup for mobile app (#156940) 2025-11-21 16:26:45 +00:00
Sebastian Schneider
1ef001f8e9 Bump aiounifi to 88 (#156867) 2025-11-21 16:26:43 +00:00
Josef Zweck
7732377fde Bump onedrive-personal-sdk to 0.0.17 (#156865) 2025-11-21 16:26:42 +00:00
puddly
b7786e589b Bump universal-silabs-flasher to 0.1.2 (#156849) 2025-11-21 16:26:41 +00:00
Joost Lekkerkerker
4f60970a91 Bump pySmartThings to 3.3.4 (#156830) 2025-11-21 16:26:40 +00:00
Thomas55555
1c1286dd57 Bump aioautomower to 2.7.1 (#156826) 2025-11-21 16:26:39 +00:00
Copilot
41c9f08f60 Fix hvv_departures to pass config_entry explicitly to DataUpdateCoordinator (#156794)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
2025-11-21 16:26:37 +00:00
Josef Zweck
fc4bfab0f7 Lamarzocco fix websocket reconnect issue (#156786)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-21 16:26:36 +00:00
epenet
769a12f74e Fix blocking call in cync (#156782) 2025-11-21 16:26:35 +00:00
Dan Raper
dabaa2bc5e Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-21 16:26:34 +00:00
Jan Bouwhuis
b674828a91 Fix missing temperature_delta device class translations (#156685) 2025-11-21 16:26:32 +00:00
Jan Bouwhuis
761da66658 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-21 16:26:31 +00:00
MarkGodwin
c8aba62301 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-21 16:26:30 +00:00
Robert Resch
07ab2e6805 Bump async-upnp-client to 0.46.0 (#156622) 2025-11-21 16:26:28 +00:00
Fredrik Mårtensson
f62e0c8c08 Fix is_matching in samsungtv config flow (#156594)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-21 16:26:27 +00:00
PaulCavill
6ca00f9dbb Bump pyiCloud to 2.2.0 (#156485) 2025-11-21 16:26:25 +00:00
Jamin
0fba80e30f Reset state on error during VOIP announcement (#156384) 2025-11-21 16:26:24 +00:00
puddly
7073c40385 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-21 16:26:23 +00:00
Charlie Rusbridger
8fb9d92daf Fix wrong BrowseError module in Kode (#155971) 2025-11-21 16:26:22 +00:00
cdnninja
2d81665f99 update methods to non deprecated methods in vesync (#155887) 2025-11-21 16:26:20 +00:00
Tom Monck JR
b398935539 Fix args passed to check_config script (#155885) 2025-11-21 16:26:19 +00:00
averybiteydinosaur
95f588aae1 Bump version of python_awair to 0.2.5 (#155798)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:18 +00:00
Hessel
ffe524d95a Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:17 +00:00
Franck Nijhof
ee05adfca1 2025.11.2 (#156620) 2025-11-14 23:09:51 +01:00
Franck Nijhof
168c915b5f Update snapshots 2025-11-14 21:43:53 +00:00
Franck Nijhof
6c80be52af Bump version to 2025.11.2 2025-11-14 21:15:12 +00:00
Simone Chemelli
ead92cdf82 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 21:14:11 +00:00
Thomas55555
c0f0cfef59 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 21:14:09 +00:00
epenet
cefc0ba96e Fix sfr_box entry reload (#156593) 2025-11-14 21:14:08 +00:00
TheJulianJES
ad091b1062 Bump ZHA to 0.0.79 (#156571) 2025-11-14 21:14:07 +00:00
TheJulianJES
876bc6d8c4 Bump ZHA to 0.0.78 (#155937) 2025-11-14 21:14:05 +00:00
Joost Lekkerkerker
9f206d4363 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 21:12:17 +00:00
starkillerOG
a2d11e6d98 Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 21:12:16 +00:00
Willem-Jan van Rootselaar
3b38af3984 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-14 21:12:14 +00:00
Joost Lekkerkerker
3875f91bb9 Bump pySmartThings to 3.3.3 (#156528) 2025-11-14 21:12:13 +00:00
Jan Čermák
c813776b0c Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-14 21:12:12 +00:00
Foscam-wangzhengyu
3afb421cba URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:10 +00:00
puddly
c16633568b Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-14 21:12:09 +00:00
Josef Zweck
87f8ff2bb4 Fix lamarzocco update status (#156442) 2025-11-14 21:12:08 +00:00
cdnninja
b423303f1e Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-14 21:12:06 +00:00
Brett Adams
f6ff222679 Fix update progress in Teslemetry (#156422) 2025-11-14 21:12:05 +00:00
Manu
0152fa0c03 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 21:12:03 +00:00
Daniel Hjelseth Høyer
37ebbe83bc Update pyMill to 0.14.1 (#156396) 2025-11-14 21:12:02 +00:00
antoniocifu
63e036d39e Fix support for Hyperion 2.1.1 (#156343)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:01 +00:00
Erik Montnemery
f0cbf34a78 Check collation of statistics_meta DB table (#156327) 2025-11-14 21:11:59 +00:00
Teemu R.
596bc89ee6 tplink: handle repeated, unknown thermostat modes gracefully (#156310) 2025-11-14 21:11:58 +00:00
Assaf Inbal
b8c877e1d2 Ituran: Don't cache properties (#156281) 2025-11-14 21:11:56 +00:00
Åke Strandberg
197d9781cb Improve logging of failing miele action commands (#156275) 2025-11-14 21:11:55 +00:00
Erik Montnemery
f3f323637e Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 21:11:54 +00:00
Joost Lekkerkerker
9748abc103 Bump pySmartThings to 3.3.2 (#156250) 2025-11-14 21:11:52 +00:00
dotvav
596f049971 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-14 21:11:51 +00:00
Foscam-wangzhengyu
dee80cb6f5 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:11:50 +00:00
Michael
b4ab73468b Fix Climate state reproduction when target temperature is None (#156220) 2025-11-14 21:11:48 +00:00
cdnninja
a300199a97 Bump pyvesync to 3.2.1 (#156195) 2025-11-14 21:11:47 +00:00
Simone Chemelli
09dd765583 Fix config flow reconfigure for Comelit (#156193) 2025-11-14 21:11:46 +00:00
starkillerOG
0c8b765415 Fix set_absolute_position angle (#156185) 2025-11-14 21:11:44 +00:00
Paul Annekov
0824ec502f Forbid to choose state in Ukraine Alarm integration (#156183) 2025-11-14 21:11:43 +00:00
Matthias Alphart
9e0e353a5f Update xknx to 3.10.1 (#156177) 2025-11-14 21:11:42 +00:00
Abílio Costa
e934b006e2 Fix MFA Notify setup flow schema (#156158) 2025-11-14 21:11:40 +00:00
Jan Rieger
05479bb8fd Bump aio-ownet to 0.0.5 (#156157) 2025-11-14 21:11:39 +00:00
TheJulianJES
d07247566d Log HomeAssistantErrors in ZHA config flow (#156075) 2025-11-14 21:11:38 +00:00
Erwin Douna
19e6097df6 Bump pyportainter 1.0.14 (#156072) 2025-11-14 21:11:36 +00:00
Erwin Douna
2cff3cf29c Bump pyportainer 1.0.13 (#155783) 2025-11-14 21:11:35 +00:00
Timothy
5cac9b8e5e Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-14 21:09:04 +00:00
Erik Montnemery
c2a516ea32 Fix progress step bugs (#155923) 2025-11-14 21:09:03 +00:00
Nojus
192b38d3e2 Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 21:09:01 +00:00
puddly
bb018e3546 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 21:09:00 +00:00
Diogo Gomes
4919d73cc5 Bump cronsim to 2.7 (#155648) 2025-11-14 21:08:58 +00:00
Franck Nijhof
f3ddffb5ff 2025.11.1 (#156076) 2025-11-07 13:29:37 -08:00
Franck Nijhof
9bdfa77fa0 Merge branch 'master' into rc 2025-11-07 12:41:56 -08:00
Franck Nijhof
c65003009f Bump version to 2025.11.1 2025-11-07 20:36:12 +00:00
Michael Hansen
0f722109b7 Bump intents to 2025.11.7 (#156063) 2025-11-07 20:35:56 +00:00
Foscam-wangzhengyu
f7d86dec3c Fix the exception caused by the missing Foscam integration key (#156022) 2025-11-07 20:35:55 +00:00
Josef Zweck
6b49c8a70c Bump onedrive-personal-sdk to 0.0.16 (#156021) 2025-11-07 20:35:54 +00:00
epenet
ab9a8f3e53 Bump tuya-device-sharing-sdk to 0.2.5 (#156014) 2025-11-07 20:35:53 +00:00
johanzander
4e12628266 Fix Growatt integration authentication error for legacy config entries (#155993)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-07 20:35:51 +00:00
Simone Chemelli
e6d8d4de42 Bump aioamazondevices to 8.0.1 (#155989) 2025-11-07 20:35:50 +00:00
tronikos
6620b90eb4 Fix SolarEdge unload failing when there are no sensors (#155979) 2025-11-07 20:35:49 +00:00
tronikos
6fd3af8891 Handle empty fields in SolarEdge config flow (#155978) 2025-11-07 20:35:48 +00:00
Åke Strandberg
46979b8418 Fix for corrupt restored state in miele consumption sensors (#155966) 2025-11-07 20:35:47 +00:00
Marc Mueller
1718a11de2 Truncate password before sending it to bcrypt (#155950) 2025-11-07 20:35:45 +00:00
Matthias Alphart
2016b1d8c7 Fix KNX Climate humidity DPT (#155942) 2025-11-07 20:35:44 +00:00
puddly
4b72e45fc2 Remove @progress_step decorator from ZHA and Hardware integration (#155867)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-07 20:35:43 +00:00
Ståle Storø Hauknes
ead5ce905b Improve scan interval for Airthings Corentium Home 2 (#155694)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-07 20:35:42 +00:00
Franck Nijhof
0b91a92554 Bump version to 2025.11.0 2025-11-05 19:22:08 +00:00
Franck Nijhof
f233f2da3f Bump version to 2025.11.0 2025-11-05 19:21:40 +00:00
Franck Nijhof
7855df92c8 2025.11 (#155440) 2025-11-05 11:20:38 -08:00
Franck Nijhof
11309f89f0 Bump version to 2025.11.0b6 2025-11-05 18:38:57 +00:00
Paulus Schoutsen
396a987035 Rename DALI Center to Sunricher DALI (#155865)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-05 18:29:29 +00:00
puddly
b7696bfb20 Allow hardware integrations to specify TX power for ZHA (#155855)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-05 18:29:27 +00:00
Bram Kragten
5cfbe2cf71 Update frontend to 20251105.0 (#155853) 2025-11-05 18:29:26 +00:00
Erik Montnemery
4e255286af Create issue to warn against using http.server_host in supervised installs (#155837) 2025-11-05 18:29:25 +00:00
Franck Nijhof
53a96af844 Bump version to 2025.11.0b5 2025-11-05 10:38:26 +00:00
Erik Montnemery
accb705d8b Fix ESPHome config entry unload (#155830) 2025-11-05 10:29:40 +00:00
Foscam-wangzhengyu
1793abce4f Bump libpyfoscamcgi to 0.0.9 (#155824) 2025-11-05 10:29:38 +00:00
Nathan Spencer
8bfed0b60c Bump pylitterbot to 2025.0.0 (#155821) 2025-11-05 10:29:37 +00:00
steinmn
016c1de2ef Set LG Thinq energy sensor state_class as total_increasing (#155816) 2025-11-05 10:29:35 +00:00
G Johansson
c270f31365 Bump holidays to 0.84 (#155802) 2025-11-05 10:29:34 +00:00
puddly
f9e06acfc7 Add progress to ZHA migration steps (#155764)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-05 10:29:32 +00:00
Bouwe Westerdijk
901558b293 Bugfix: implement RestoreState and bump backend for Plugwise climate (#155126) 2025-11-05 10:29:31 +00:00
Aarni Koskela
c09cf36345 Bump ruuvitag-ble to 0.3.0 (#155720) 2025-11-05 11:14:31 +01:00
Bram Kragten
926627b49c Bump version to 2025.11.0b4 2025-11-04 20:38:30 +01:00
Bram Kragten
a8eeba9c5f Update frontend to 20251104.0 (#155799) 2025-11-04 20:38:02 +01:00
Paul Bottein
e4591c27c0 Rename safety panel to security panel (#155795) 2025-11-04 20:38:01 +01:00
starkillerOG
40dedec602 Bump reolink-aio to 0.16.4 (#155776) 2025-11-04 20:38:00 +01:00
Matt Zimmerman
3a65b5ca70 Update python-smarttub to 0.0.45 (#155768) 2025-11-04 20:37:59 +01:00
puddly
dbeb82861f Bump ZHA to 0.0.77 (#155766) 2025-11-04 20:37:58 +01:00
Brett Adams
e43c35ab2d Bump Tesla Fleet API to v1.2.5 (#155763) 2025-11-04 20:37:57 +01:00
karwosts
b4c4fdefe3 Fix Ambient Weather incorrect state classes (#155751) 2025-11-04 20:37:56 +01:00
Fredrik Mårtensson
965dd7c557 Catch exception from libsoundtouch if device not available (#155749)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-11-04 20:37:55 +01:00
TheJulianJES
9a921f2c8e Fix ZBT-2 Thread to Zigbee migration discovery failing (#155735) 2025-11-04 20:37:54 +01:00
cdnninja
aaae3244a8 Correct Vesync Humidifier Mode (#155638) 2025-11-04 20:37:53 +01:00
TheJulianJES
40ff100900 Add ZHA migration retry steps for unplugged adapters (#155537) 2025-11-04 20:37:52 +01:00
tronikos
1b62b2309f Remove Enmax Energy virtual integration (#155475) 2025-11-04 20:37:51 +01:00
puddly
9d57251aea Fix non-unique ZHA serial port paths and migrate USB integration to always list unique paths (#155019) 2025-11-04 20:37:50 +01:00
Bram Kragten
f877614e7f Bump version to 2025.11.0b3 2025-11-03 20:46:58 +01:00
Mike Degatano
170e1e87c7 Disable deprecated addon repair (#155739) 2025-11-03 20:46:03 +01:00
Michael Hansen
e1feba5c86 Use character code in language matching (voice) (#155738) 2025-11-03 20:46:02 +01:00
Bram Kragten
9bf52b7966 Update frontend to 20251103.0 (#155734) 2025-11-03 20:46:02 +01:00
Simone Chemelli
3bc61a3564 Bump aioamazondevices to 6.5.6 (#155723) 2025-11-03 20:46:01 +01:00
Bram Kragten
d2ba94e1bf Bump version to 2025.11.0b2 2025-11-03 08:04:32 +01:00
Joost Lekkerkerker
9a4ed82399 Bump python-open-router to 0.3.2 (#155700) 2025-11-03 08:04:12 +01:00
cdnninja
b5136d01aa fix vesync mist level value (#155697) 2025-11-03 08:04:11 +01:00
starkillerOG
d3e05090ea Bump reolink_aio to 0.16.3 (#155692) 2025-11-03 08:04:10 +01:00
Michael
7e75ca7af9 Revert "Remove neato integration (#154902)" (#155685) 2025-11-03 08:04:10 +01:00
Matthias Alphart
6616b5775f Fix KNX climate loading min/max temp from UI config (#155682) 2025-11-03 08:04:09 +01:00
Robert Resch
69b82d4c59 Bump deebot-client to 16.3.0 (#155681) 2025-11-03 08:04:08 +01:00
Bram Kragten
6b9709677a Fix device tracker name & icon for Volvo integration (#155667) 2025-11-03 08:03:00 +01:00
Robert Resch
a4e9c82c84 Bump deebot-client to 16.2.0 (#155642) 2025-11-03 07:57:45 +01:00
cdnninja
de86bedb80 vesync don't assume fan speed target (#155617) 2025-11-03 07:57:44 +01:00
Matthias Alphart
9111c6df90 Update knx-frontend to 2025.10.31.195356 (#155569) 2025-11-03 07:57:43 +01:00
Jordan Harvey
751f6bddb1 Update pynintendoparental to version 1.1.3 (#155568) 2025-11-03 07:57:42 +01:00
Josef Zweck
c9a61de0a1 Bump onedrive-personal-sdk to 0.0.15 (#155540) 2025-11-03 07:57:41 +01:00
Sid
01fb46d903 Bump eheimdigital to 1.4.0 (#155539) 2025-11-03 07:57:41 +01:00
cdnninja
d26f61c9fe Bump pyvesync to 3.1.4 (#155533) 2025-11-03 07:57:39 +01:00
Robert Resch
a47a144312 Bump uv to 0.9.6 (#155521) 2025-11-03 07:57:38 +01:00
Erwin Douna
69cf4f99d1 Portainer refactor CONF_VERIFY_SSL (#155520) 2025-11-03 07:57:37 +01:00
Shay Levy
e6c757c187 Fix Shelly irrigation zone ID retrieval with Sleepy devices (#155514) 2025-11-03 07:57:36 +01:00
hanwg
a36b0e2f3f Fix event entity state update for Telegram bot (#155510) 2025-11-03 07:57:35 +01:00
Jakob Schlyter
1a7c6cd96c Update regions and voices used by Amazon Polly (#155501) 2025-11-03 07:57:34 +01:00
tronikos
ba3e538402 Bump opower to 0.15.9 (#155473) 2025-11-03 07:57:33 +01:00
Mike Degatano
b2cd08aa65 Addon progress reporting follow-up from feedback (#155464) 2025-11-03 07:57:33 +01:00
karwosts
06dcd25a16 Hassfest check for invalid localization placeholders (#155216) 2025-11-03 07:57:32 +01:00
Bram Kragten
fd36782bae Bump version to 2025.11.0b1 2025-10-30 20:12:15 +01:00
Bram Kragten
ed4573db57 Update frontend to 20251029.1 (#155513) 2025-10-30 20:11:55 +01:00
Erwin Douna
78373a6483 Firefly fix config flow (#155503) 2025-10-30 20:11:54 +01:00
Sab44
8455c35bec Bump librehardwaremonitor-api to 1.5.0 (#155492) 2025-10-30 20:11:53 +01:00
Kinachi249
00887a2f3f Bump PyCync to 0.4.3 (#155477) 2025-10-30 20:11:52 +01:00
Erwin Douna
f1ca7543fa Bump pyportainer 1.0.12 (#155468) 2025-10-30 20:11:51 +01:00
Abílio Costa
bb72b24ba9 Mock async_setup_entry in BMW Connected Drive config flow test (#155446) 2025-10-30 20:11:50 +01:00
Andrea Turri
322a27d992 Miele RestoreSensor: restore native value rather than stringified state (#152750)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
2025-10-30 20:11:49 +01:00
hanwg
a3b516110b Deprecate legacy Telegram notify service (#150720)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2025-10-30 20:11:48 +01:00
Bram Kragten
95ac5c0183 Bump version to 2025.11.0b0 2025-10-29 18:53:20 +01:00
341 changed files with 10241 additions and 1516 deletions

View File

@@ -361,6 +361,7 @@ homeassistant.components.myuplink.*
homeassistant.components.nam.* homeassistant.components.nam.*
homeassistant.components.nanoleaf.* homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.* homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.* homeassistant.components.nest.*
homeassistant.components.netatmo.* homeassistant.components.netatmo.*
homeassistant.components.network.* homeassistant.components.network.*

4
CODEOWNERS generated
View File

@@ -1543,8 +1543,8 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core /homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core /tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali_center/ @niracler /homeassistant/components/sunricher_dali/ @niracler
/tests/components/sunricher_dali_center/ @niracler /tests/components/sunricher_dali/ @niracler
/homeassistant/components/supla/ @mwegrzynek /homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen

4
Dockerfile generated
View File

@@ -25,13 +25,13 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.9.5 RUN pip3 install uv==0.9.6
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
cosign: cosign:
base_identity: https://github.com/home-assistant/docker/.* base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.*

View File

@@ -6,7 +6,6 @@ Sending HOTP through notify service
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict
import logging import logging
from typing import Any, cast from typing import Any, cast
@@ -304,14 +303,15 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
if not self._available_notify_services: if not self._available_notify_services:
return self.async_abort(reason="no_available_service") return self.async_abort(reason="no_available_service")
schema: dict[str, Any] = OrderedDict() schema = vol.Schema(
schema["notify_service"] = vol.In(self._available_notify_services) {
schema["target"] = vol.Optional(str) vol.Required("notify_service"): vol.In(self._available_notify_services),
vol.Optional("target"): str,
return self.async_show_form( }
step_id="init", data_schema=vol.Schema(schema), errors=errors
) )
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
async def async_step_setup( async def async_step_setup(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> FlowResult: ) -> FlowResult:

View File

@@ -179,12 +179,18 @@ class Data:
user_hash = base64.b64decode(found["password"]) user_hash = base64.b64decode(found["password"])
# bcrypt.checkpw is timing-safe # bcrypt.checkpw is timing-safe
if not bcrypt.checkpw(password.encode(), user_hash): # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
if not bcrypt.checkpw(password.encode()[:72], user_hash):
raise InvalidAuth raise InvalidAuth
def hash_password(self, password: str, for_storage: bool = False) -> bytes: def hash_password(self, password: str, for_storage: bool = False) -> bytes:
"""Encode a password.""" """Encode a password."""
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
if for_storage: if for_storage:
hashed = base64.b64encode(hashed) hashed = base64.b64encode(hashed)

View File

@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN, MFCT_ID from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None: if user_input is not None:
if ( if self._discovered_device.device.firmware.need_firmware_upgrade:
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
return self.async_abort(reason="firmware_upgrade_required") return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={} title=self.context["title_placeholders"]["name"],
data={DEVICE_MODEL: self._discovered_device.device.model.value},
) )
self._set_confirm_only() self._set_confirm_only()
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device = discovery self._discovered_device = discovery
return self.async_create_entry(title=discovery.name, data={}) return self.async_create_entry(
title=discovery.name,
data={DEVICE_MODEL: discovery.device.model.value},
)
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = [] devices: list[BluetoothServiceInfoBleak] = []

View File

@@ -1,11 +1,16 @@
"""Constants for Airthings BLE.""" """Constants for Airthings BLE."""
from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble" DOMAIN = "airthings_ble"
MFCT_ID = 820 MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³" VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L" VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300 DEFAULT_SCAN_INTERVAL = 300
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
MAX_RETRIES_AFTER_STARTUP = 5 MAX_RETRIES_AFTER_STARTUP = 5

View File

@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .const import (
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
self.airthings = AirthingsBluetoothDeviceData( self.airthings = AirthingsBluetoothDeviceData(
_LOGGER, hass.config.units is METRIC_SYSTEM _LOGGER, hass.config.units is METRIC_SYSTEM
) )
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
device_model, DEFAULT_SCAN_INTERVAL
)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry, config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), update_interval=timedelta(seconds=interval),
) )
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
) )
self.ble_device = ble_device self.ble_device = ble_device
if DEVICE_MODEL not in self.config_entry.data:
_LOGGER.debug("Fetching device info for migration")
try:
data = await self.airthings.update_device(self.ble_device)
except Exception as err:
raise UpdateFailed(
f"Unable to fetch data for migration: {err}"
) from err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
)
self.update_interval = timedelta(
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
data.model.value, DEFAULT_SCAN_INTERVAL
)
)
async def _async_update_data(self) -> AirthingsDevice: async def _async_update_data(self) -> AirthingsDevice:
"""Get data from Airthings BLE.""" """Get data from Airthings BLE."""
try: try:
data = await self.airthings.update_device(self.ble_device) data = await self.airthings.update_device(self.ble_device)
except Exception as err: except Exception as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data return data

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.metadata import SENSOR_STATE_OFF
from aioamazondevices.const import SENSOR_STATE_OFF from aioamazondevices.structures import AmazonDevice
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN, DOMAIN as BINARY_SENSOR_DOMAIN,

View File

@@ -2,12 +2,13 @@
from datetime import timedelta from datetime import timedelta
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import ( from aioamazondevices.exceptions import (
CannotAuthenticate, CannotAuthenticate,
CannotConnect, CannotConnect,
CannotRetrieveData, CannotRetrieveData,
) )
from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -15,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -42,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title, name=entry.title,
config_entry=entry, config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL), update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
) )
self.api = AmazonEchoApi( self.api = AmazonEchoApi(
session, session,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME

View File

@@ -1,7 +1,7 @@
"""Defines a base Alexa Devices entity.""" """Defines a base Alexa Devices entity."""
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
from aioamazondevices.const import SPEAKER_GROUP_MODEL from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription

View File

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

View File

@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View File

@@ -7,12 +7,12 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.schedules import (
from aioamazondevices.const import (
NOTIFICATION_ALARM, NOTIFICATION_ALARM,
NOTIFICATION_REMINDER, NOTIFICATION_REMINDER,
NOTIFICATION_TIMER, NOTIFICATION_TIMER,
) )
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,

View File

@@ -1,6 +1,6 @@
"""Support for services.""" """Support for services."""
from aioamazondevices.sounds import SOUNDS_LIST from aioamazondevices.const.sounds import SOUNDS_LIST
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.switch import ( from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN, DOMAIN as SWITCH_DOMAIN,

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN

View File

@@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="daily_rain", translation_key="daily_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION, device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2, suggested_display_precision=2,
), ),
SensorEntityDescription( SensorEntityDescription(
@@ -150,7 +150,7 @@ SENSOR_DESCRIPTIONS = (
key=TYPE_LIGHTNING_PER_DAY, key=TYPE_LIGHTNING_PER_DAY,
translation_key="lightning_strikes_per_day", translation_key="lightning_strikes_per_day",
native_unit_of_measurement="strikes", native_unit_of_measurement="strikes",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
SensorEntityDescription( SensorEntityDescription(
@@ -182,7 +182,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="monthly_rain", translation_key="monthly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION, device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
@@ -229,7 +229,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="weekly_rain", translation_key="weekly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION, device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
@@ -262,7 +262,7 @@ SENSOR_DESCRIPTIONS = (
translation_key="yearly_rain", translation_key="yearly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION, device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/awair", "documentation": "https://www.home-assistant.io/integrations/awair",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["python_awair"], "loggers": ["python_awair"],
"requirements": ["python-awair==0.2.4"], "requirements": ["python-awair==0.2.5"],
"zeroconf": [ "zeroconf": [
{ {
"name": "awair*", "name": "awair*",

View File

@@ -8,6 +8,6 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "calculated", "iot_class": "calculated",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.2.1"], "requirements": ["cronsim==2.7", "securetar==2025.2.1"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -189,7 +189,7 @@ class BryantEvolutionClimate(ClimateEntity):
return HVACAction.HEATING return HVACAction.HEATING
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="failed_to_parse_hvac_mode", translation_key="failed_to_parse_hvac_action",
translation_placeholders={ translation_placeholders={
"mode_and_active": mode_and_active, "mode_and_active": mode_and_active,
"current_temperature": str(self.current_temperature), "current_temperature": str(self.current_temperature),

View File

@@ -24,7 +24,7 @@
}, },
"exceptions": { "exceptions": {
"failed_to_parse_hvac_action": { "failed_to_parse_hvac_action": {
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}" "message": "Could not determine HVAC action: {mode_and_active}, {current_temperature}, {target_temperature_low}"
}, },
"failed_to_parse_hvac_mode": { "failed_to_parse_hvac_mode": {
"message": "Cannot parse response to HVACMode: {mode}" "message": "Cannot parse response to HVACMode: {mode}"

View File

@@ -74,7 +74,10 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.fast_coordinator, data) super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
# Set temperature range if available, otherwise use Home Assistant defaults
if data.static.min_temp is not None and data.static.min_temp.value is not None:
self._attr_min_temp = data.static.min_temp.value self._attr_min_temp = data.static.min_temp.value
if data.static.max_temp is not None and data.static.max_temp.value is not None:
self._attr_max_temp = data.static.max_temp.value self._attr_max_temp = data.static.max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit

View File

@@ -7,7 +7,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.0"], "requirements": ["python-bsblan==3.1.1"],
"zeroconf": [ "zeroconf": [
{ {
"name": "bsb-lan*", "name": "bsb-lan*",

View File

@@ -57,9 +57,9 @@ async def _async_reproduce_states(
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state}) await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
if ( if (
(ATTR_TEMPERATURE in state.attributes) (state.attributes.get(ATTR_TEMPERATURE) is not None)
or (ATTR_TARGET_TEMP_HIGH in state.attributes) or (state.attributes.get(ATTR_TARGET_TEMP_HIGH) is not None)
or (ATTR_TARGET_TEMP_LOW in state.attributes) or (state.attributes.get(ATTR_TARGET_TEMP_LOW) is not None)
): ):
await call_service( await call_service(
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging import logging
from typing import cast from typing import Any, cast
from hass_nabucasa import Cloud from hass_nabucasa import Cloud
import voluptuous as vol import voluptuous as vol
@@ -85,6 +85,10 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
"CLOUD_CONNECTION_STATE" "CLOUD_CONNECTION_STATE"
) )
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
"CLOUDHOOKS_UPDATED"
)
STARTUP_REPAIR_DELAY = 1 # 1 hour STARTUP_REPAIR_DELAY = 1 # 1 hour
ALEXA_ENTITY_SCHEMA = vol.Schema( ALEXA_ENTITY_SCHEMA = vol.Schema(
@@ -240,6 +244,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id) await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
@callback
def async_listen_cloudhook_change(
hass: HomeAssistant,
webhook_id: str,
on_change: Callable[[dict[str, Any] | None], None],
) -> Callable[[], None]:
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
@callback
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
"""Handle cloudhooks updated signal."""
on_change(cloudhooks.get(webhook_id))
return async_dispatcher_connect(
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
)
@bind_hass @bind_hass
@callback @callback
def async_remote_ui_url(hass: HomeAssistant) -> str: def async_remote_ui_url(hass: HomeAssistant) -> str:
@@ -287,7 +309,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
_remote_handle_prefs_updated(cloud) _handle_prefs_updated(hass, cloud)
_setup_services(hass, prefs) _setup_services(hass, prefs)
async def async_startup_repairs(_: datetime) -> None: async def async_startup_repairs(_: datetime) -> None:
@@ -371,26 +393,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback @callback
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
"""Handle remote preferences updated.""" """Register handler for cloud preferences updates."""
cur_pref = cloud.client.prefs.remote_enabled cur_remote_enabled = cloud.client.prefs.remote_enabled
cur_cloudhooks = cloud.client.prefs.cloudhooks
lock = asyncio.Lock() lock = asyncio.Lock()
# Sync remote connection with prefs async def on_prefs_updated(prefs: CloudPreferences) -> None:
async def remote_prefs_updated(prefs: CloudPreferences) -> None: """Handle cloud preferences updates."""
"""Update remote status.""" nonlocal cur_remote_enabled
nonlocal cur_pref nonlocal cur_cloudhooks
# Lock protects cur_ state variables from concurrent updates
async with lock: async with lock:
if prefs.remote_enabled == cur_pref: if cur_cloudhooks != prefs.cloudhooks:
cur_cloudhooks = prefs.cloudhooks
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
if prefs.remote_enabled == cur_remote_enabled:
return return
if cur_pref := prefs.remote_enabled: if cur_remote_enabled := prefs.remote_enabled:
await cloud.remote.connect() await cloud.remote.connect()
else: else:
await cloud.remote.disconnect() await cloud.remote.disconnect()
cloud.client.prefs.async_listen_updates(remote_prefs_updated) cloud.client.prefs.async_listen_updates(on_prefs_updated)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.ssl import get_default_context
from .const import ( from .const import (
CONF_AUTHORIZE_STRING, CONF_AUTHORIZE_STRING,
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
expires_at=entry.data[CONF_EXPIRES_AT], expires_at=entry.data[CONF_EXPIRES_AT],
) )
cync_auth = Auth(async_get_clientsession(hass), user=user_info) cync_auth = Auth(async_get_clientsession(hass), user=user_info)
ssl_context = get_default_context()
try: try:
cync = await Cync.create(cync_auth) cync = await Cync.create(
auth=cync_auth,
ssl_context=ssl_context,
)
except AuthFailedError as ex: except AuthFailedError as ex:
raise ConfigEntryAuthFailed("User token invalid") from ex raise ConfigEntryAuthFailed("User token invalid") from ex
except CyncError as ex: except CyncError as ex:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pycync==0.4.2"] "requirements": ["pycync==0.4.3"]
} }

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -167,6 +169,7 @@ class DecoraWifiLight(LightEntity):
except ValueError: except ValueError:
_LOGGER.error("Failed to turn off myLeviton switch") _LOGGER.error("Failed to turn off myLeviton switch")
@Throttle(timedelta(seconds=30))
def update(self) -> None: def update(self) -> None:
"""Fetch new state data for this switch.""" """Fetch new state data for this switch."""
try: try:

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.45.0"], "requirements": ["async-upnp-client==0.46.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1", "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"] "requirements": ["py-sucks==0.9.11", "deebot-client==16.3.0"]
} }

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eheimdigital"], "loggers": ["eheimdigital"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["eheimdigital==1.3.0"], "requirements": ["eheimdigital==1.4.0"],
"zeroconf": [ "zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." } { "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
] ]

View File

@@ -1 +0,0 @@
"""Virtual integration: Enmax Energy."""

View File

@@ -1,6 +0,0 @@
{
"domain": "enmax",
"name": "Enmax Energy",
"integration_type": "virtual",
"supported_by": "opower"
}

View File

@@ -75,10 +75,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
"""Unload an esphome config entry.""" """Unload an esphome config entry."""
entry_data = await cleanup_instance(entry) unload_ok = await hass.config_entries.async_unload_platforms(
return await hass.config_entries.async_unload_platforms( entry, entry.runtime_data.loaded_platforms
entry, entry_data.loaded_platforms
) )
if unload_ok:
await cleanup_instance(entry)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:

View File

@@ -40,7 +40,9 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
client = Firefly( client = Firefly(
api_url=data[CONF_URL], api_url=data[CONF_URL],
api_key=data[CONF_API_KEY], api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass), session=async_get_clientsession(
hass=hass, verify_ssl=data[CONF_VERIFY_SSL]
),
) )
await client.get_about() await client.get_about()
except FireflyAuthenticationError: except FireflyAuthenticationError:

View File

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

View File

@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
supports_speak_volume_adjustment: bool supports_speak_volume_adjustment: bool
supports_pet_adjustment: bool supports_pet_adjustment: bool
supports_car_adjustment: bool supports_car_adjustment: bool
supports_human_adjustment: bool
supports_wdr_adjustment: bool supports_wdr_adjustment: bool
supports_hdr_adjustment: bool supports_hdr_adjustment: bool
@@ -115,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None is_open_wdr = None
is_open_hdr = None is_open_hdr = None
reserve3 = product_info.get("reserve4") reserve3 = product_info.get("reserve4")
model = product_info.get("model")
model_int = int(model) if model is not None else 7002
if model_int > 7001:
reserve3_int = int(reserve3) if reserve3 is not None else 0 reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256)) supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
if supports_wdr_adjustment_val: if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode() ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 mode = (
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
)
is_open_wdr = bool(int(mode)) is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val: elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode() ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 mode = (
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
)
is_open_hdr = bool(int(mode)) is_open_hdr = bool(int(mode))
else:
supports_wdr_adjustment_val = False
supports_hdr_adjustment_val = False
ret_sw, software_capabilities = self.session.getSWCapabilities() ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = ( supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32) bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0 if ret_sw == 0
@@ -144,24 +153,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
if ret_sw == 0 if ret_sw == 0
else False else False
) )
ret_md, mothion_config_val = self.session.get_motion_detect_config() human_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities2")) & 128)
if ret_sw == 0
else False
)
ret_md, motion_config_val = self.session.get_motion_detect_config()
if pet_adjustment_val: if pet_adjustment_val:
is_pet_detection_on_val = ( is_pet_detection_on_val = (
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_pet_detection_on_val = False is_pet_detection_on_val = False
if car_adjustment_val: if car_adjustment_val:
is_car_detection_on_val = ( is_car_detection_on_val = (
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_car_detection_on_val = False is_car_detection_on_val = False
if human_adjustment_val:
is_human_detection_on_val = ( is_human_detection_on_val = (
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
) )
else:
is_human_detection_on_val = False
return FoscamDeviceInfo( return FoscamDeviceInfo(
dev_info=dev_info, dev_info=dev_info,
@@ -179,6 +196,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
supports_pet_adjustment=pet_adjustment_val, supports_pet_adjustment=pet_adjustment_val,
supports_car_adjustment=car_adjustment_val, supports_car_adjustment=car_adjustment_val,
supports_human_adjustment=human_adjustment_val,
supports_hdr_adjustment=supports_hdr_adjustment_val, supports_hdr_adjustment=supports_hdr_adjustment_val,
supports_wdr_adjustment=supports_wdr_adjustment_val, supports_wdr_adjustment=supports_wdr_adjustment_val,
is_open_wdr=is_open_wdr, is_open_wdr=is_open_wdr,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/foscam", "documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"], "loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.8"] "requirements": ["libpyfoscamcgi==0.0.9"]
} }

View File

@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
native_value_fn=lambda data: data.is_human_detection_on, native_value_fn=lambda data: data.is_human_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False), turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True), turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
), ),
] ]

View File

@@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass)) hass.http.app.router.register_resource(IndexView(repo_path, hass))
async_register_built_in_panel(hass, "light") async_register_built_in_panel(hass, "light")
async_register_built_in_panel(hass, "safety") async_register_built_in_panel(hass, "security")
async_register_built_in_panel(hass, "climate") async_register_built_in_panel(hass, "climate")
async_register_built_in_panel(hass, "profile") async_register_built_in_panel(hass, "profile")

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251029.0"] "requirements": ["home-assistant-frontend==20251105.1"]
} }

View File

@@ -60,35 +60,6 @@ from .server import Server
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_FFMPEG = "ffmpeg" _FFMPEG = "ffmpeg"
_SUPPORTED_STREAMS = frozenset(
(
"bubble",
"dvrip",
"expr",
_FFMPEG,
"gopro",
"homekit",
"http",
"https",
"httpx",
"isapi",
"ivideon",
"kasa",
"nest",
"onvif",
"roborock",
"rtmp",
"rtmps",
"rtmpx",
"rtsp",
"rtsps",
"rtspx",
"tapo",
"tcp",
"webrtc",
"webtorrent",
)
)
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@@ -197,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
return False return False
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
await provider.initialize()
entry.async_on_unload(async_register_webrtc_provider(hass, provider)) entry.async_on_unload(async_register_webrtc_provider(hass, provider))
return True return True
@@ -228,16 +200,21 @@ class WebRTCProvider(CameraWebRTCProvider):
self._session = session self._session = session
self._rest_client = rest_client self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
self._supported_schemes: set[str] = set()
@property @property
def domain(self) -> str: def domain(self) -> str:
"""Return the integration domain of the provider.""" """Return the integration domain of the provider."""
return DOMAIN return DOMAIN
async def initialize(self) -> None:
"""Initialize the provider."""
self._supported_schemes = await self._rest_client.schemes.list()
@callback @callback
def async_is_supported(self, stream_source: str) -> bool: def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source.""" """Return if this provider is supports the Camera as source."""
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS return stream_source.partition(":")[0] in self._supported_schemes
async def async_handle_async_webrtc_offer( async def async_handle_async_webrtc_offer(
self, self,

View File

@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.11" RECOMMENDED_VERSION = "1.9.12"

View File

@@ -8,6 +8,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["go2rtc-client==0.2.1"], "requirements": ["go2rtc-client==0.3.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -29,8 +29,18 @@ _RESPAWN_COOLDOWN = 1
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant _GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
# Do not edit it manually # Do not edit it manually
app:
modules: {app_modules}
api: api:
listen: "{api_ip}:{api_port}" listen: "{api_ip}:{api_port}"
allow_paths: {api_allow_paths}
# ffmpeg needs the exec module
# Restrict execution to only ffmpeg binary
exec:
allow_paths:
- ffmpeg
rtsp: rtsp:
listen: "127.0.0.1:18554" listen: "127.0.0.1:18554"
@@ -40,6 +50,43 @@ webrtc:
ice_servers: [] ice_servers: []
""" """
_APP_MODULES = (
"api",
"exec", # Execution module for ffmpeg
"ffmpeg",
"http",
"mjpeg",
"onvif",
"rtmp",
"rtsp",
"srtp",
"webrtc",
"ws",
)
_API_ALLOW_PATHS = (
"/", # UI static page and version control
"/api", # Main API path
"/api/frame.jpeg", # Snapshot functionality
"/api/schemes", # Supported stream schemes
"/api/streams", # Stream management
"/api/webrtc", # Webrtc functionality
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
)
# Additional modules when UI is enabled
_UI_APP_MODULES = (
*_APP_MODULES,
"debug",
)
# Additional api paths when UI is enabled
_UI_API_ALLOW_PATHS = (
*_API_ALLOW_PATHS,
"/api/config", # UI config view
"/api/log", # UI log view
"/api/streams.dot", # UI network view
)
_LOG_LEVEL_MAP = { _LOG_LEVEL_MAP = {
"TRC": logging.DEBUG, "TRC": logging.DEBUG,
"DBG": logging.DEBUG, "DBG": logging.DEBUG,
@@ -61,14 +108,34 @@ class Go2RTCWatchdogError(HomeAssistantError):
"""Raised on watchdog error.""" """Raised on watchdog error."""
def _create_temp_file(api_ip: str) -> str: def _format_list_for_yaml(items: tuple[str, ...]) -> str:
"""Format a list of strings for yaml config."""
if not items:
return "[]"
formatted_items = ",".join(f'"{item}"' for item in items)
return f"[{formatted_items}]"
def _create_temp_file(enable_ui: bool) -> str:
"""Create temporary config file.""" """Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
api_ip = _LOCALHOST_IP
if enable_ui:
app_modules = _UI_APP_MODULES
api_paths = _UI_API_ALLOW_PATHS
# Listen on all interfaces for allowing access from all ips
api_ip = ""
# Set delete=False to prevent the file from being deleted when the file is closed # Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually # Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write( file.write(
_GO2RTC_CONFIG_FORMAT.format( _GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_port=HA_MANAGED_API_PORT api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
).encode() ).encode()
) )
return file.name return file.name
@@ -86,10 +153,7 @@ class Server:
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event() self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP self._enable_ui = enable_ui
if enable_ui:
# Listen on all interfaces for allowing access from all ips
self._api_ip = ""
self._watchdog_task: asyncio.Task | None = None self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = [] self._watchdog_tasks: list[asyncio.Task] = []
@@ -104,7 +168,7 @@ class Server:
"""Start the server.""" """Start the server."""
_LOGGER.debug("Starting go2rtc server") _LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job( config_file = await self._hass.async_add_executor_job(
_create_temp_file, self._api_ip _create_temp_file, self._enable_ui
) )
self._startup_complete.clear() self._startup_complete.clear()

View File

@@ -136,6 +136,21 @@ async def async_setup_entry(
new_data[CONF_URL] = url new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data) hass.config_entries.async_update_entry(config_entry, data=new_data)
# Migrate legacy config entries without auth_type field
if CONF_AUTH_TYPE not in config:
new_data = dict(config_entry.data)
# Detect auth type based on which fields are present
if CONF_TOKEN in config:
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
elif CONF_USERNAME in config:
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
else:
raise ConfigEntryError(
"Unable to determine authentication type from config entry."
)
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
# Determine API version # Determine API version
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1" api_version = "v1"

View File

@@ -620,7 +620,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Pop add-on data # Unload coordinator
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
coordinator.unload()
# Pop coordinator
hass.data.pop(ADDONS_COORDINATOR, None) hass.data.pop(ADDONS_COORDINATOR, None)
return unload_ok return unload_ok

View File

@@ -563,3 +563,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
self.async_set_updated_data(data) self.async_set_updated_data(data)
except SupervisorError as err: except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
@callback
def unload(self) -> None:
"""Clean up when config entry unloaded."""
self.jobs.unload()

View File

@@ -44,7 +44,6 @@ from .const import (
EVENT_SUPPORTED_CHANGED, EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS, EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
@@ -87,7 +86,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime", "issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE, ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
} }
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -3,6 +3,7 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from functools import partial from functools import partial
import logging
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -29,6 +30,8 @@ from .const import (
) )
from .handler import get_supervisor_client from .handler import get_supervisor_client
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class JobSubscription: class JobSubscription:
@@ -45,7 +48,7 @@ class JobSubscription:
event_callback: Callable[[Job], Any] event_callback: Callable[[Job], Any]
uuid: str | None = None uuid: str | None = None
name: str | None = None name: str | None = None
reference: str | None | type[Any] = Any reference: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Validate at least one filter option is present.""" """Validate at least one filter option is present."""
@@ -58,7 +61,7 @@ class JobSubscription:
"""Return true if job matches subscription filters.""" """Return true if job matches subscription filters."""
if self.uuid: if self.uuid:
return job.uuid == self.uuid return job.uuid == self.uuid
return job.name == self.name and self.reference in (Any, job.reference) return job.name == self.name and self.reference in (None, job.reference)
class SupervisorJobs: class SupervisorJobs:
@@ -70,6 +73,7 @@ class SupervisorJobs:
self._supervisor_client = get_supervisor_client(hass) self._supervisor_client = get_supervisor_client(hass)
self._jobs: dict[UUID, Job] = {} self._jobs: dict[UUID, Job] = {}
self._subscriptions: set[JobSubscription] = set() self._subscriptions: set[JobSubscription] = set()
self._dispatcher_disconnect: Callable[[], None] | None = None
@property @property
def current_jobs(self) -> list[Job]: def current_jobs(self) -> list[Job]:
@@ -79,20 +83,24 @@ class SupervisorJobs:
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
"""Subscribe to updates for job. Return callback is used to unsubscribe. """Subscribe to updates for job. Return callback is used to unsubscribe.
If any jobs match the subscription at the time this is called, creates If any jobs match the subscription at the time this is called, runs the
tasks to run their callback on it. callback on them.
""" """
self._subscriptions.add(subscription) self._subscriptions.add(subscription)
# As these are callbacks they are safe to run in the event loop # Run the callback on each existing match
# We wrap these in an asyncio task so subscribing does not wait on the logic # We catch all errors to prevent an error in one from stopping the others
if matches := [job for job in self._jobs.values() if subscription.matches(job)]: for match in [job for job in self._jobs.values() if subscription.matches(job)]:
try:
async def event_callback_async(job: Job) -> Any: return subscription.event_callback(match)
return subscription.event_callback(job) except Exception as err: # noqa: BLE001
_LOGGER.error(
for match in matches: "Error encountered processing Supervisor Job (%s %s %s) - %s",
self._hass.async_create_task(event_callback_async(match)) match.name,
match.reference,
match.uuid,
err,
)
return partial(self._subscriptions.discard, subscription) return partial(self._subscriptions.discard, subscription)
@@ -131,7 +139,7 @@ class SupervisorJobs:
# If this is the first update register to receive Supervisor events # If this is the first update register to receive Supervisor events
if first_update: if first_update:
async_dispatcher_connect( self._dispatcher_disconnect = async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
) )
@@ -158,3 +166,14 @@ class SupervisorJobs:
for sub in self._subscriptions: for sub in self._subscriptions:
if sub.matches(job): if sub.matches(job):
sub.event_callback(job) sub.event_callback(job)
# If the job is done, pop it from our cache if present after processing is done
if job.done and job.uuid in self._jobs:
del self._jobs[job.uuid]
@callback
def unload(self) -> None:
"""Unregister with dispatcher on config entry unload."""
if self._dispatcher_disconnect:
self._dispatcher_disconnect()
self._dispatcher_disconnect = None

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.83", "babel==2.15.0"] "requirements": ["holidays==0.84", "babel==2.15.0"]
} }

View File

@@ -39,6 +39,8 @@ from .const import (
NABU_CASA_FIRMWARE_RELEASES_URL, NABU_CASA_FIRMWARE_RELEASES_URL,
PID, PID,
PRODUCT, PRODUCT,
RADIO_TX_POWER_DBM_BY_COUNTRY,
RADIO_TX_POWER_DBM_DEFAULT,
SERIAL_NUMBER, SERIAL_NUMBER,
VID, VID,
) )
@@ -74,7 +76,17 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods.""" """Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -102,6 +114,21 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
next_step_id="finish_thread_installation", next_step_id="finish_thread_installation",
) )
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
country = self.hass.config.country
if country is None:
tx_power = RADIO_TX_POWER_DBM_DEFAULT
else:
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
country, RADIO_TX_POWER_DBM_DEFAULT
)
return {
"tx_power": tx_power,
}
class HomeAssistantConnectZBT2ConfigFlow( class HomeAssistantConnectZBT2ConfigFlow(
ZBT2FirmwareMixin, ZBT2FirmwareMixin,
@@ -112,7 +139,6 @@ class HomeAssistantConnectZBT2ConfigFlow(
VERSION = 1 VERSION = 1
MINOR_VERSION = 1 MINOR_VERSION = 1
ZIGBEE_BAUDRATE = 460800
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""

View File

@@ -1,5 +1,7 @@
"""Constants for the Home Assistant Connect ZBT-2 integration.""" """Constants for the Home Assistant Connect ZBT-2 integration."""
from homeassistant.generated.countries import COUNTRIES
DOMAIN = "homeassistant_connect_zbt2" DOMAIN = "homeassistant_connect_zbt2"
NABU_CASA_FIRMWARE_RELEASES_URL = ( NABU_CASA_FIRMWARE_RELEASES_URL = (
@@ -17,3 +19,59 @@ VID = "vid"
DEVICE = "device" DEVICE = "device"
HARDWARE_NAME = "Home Assistant Connect ZBT-2" HARDWARE_NAME = "Home Assistant Connect ZBT-2"
RADIO_TX_POWER_DBM_DEFAULT = 8
RADIO_TX_POWER_DBM_BY_COUNTRY = {
# EU Member States
"AT": 10,
"BE": 10,
"BG": 10,
"HR": 10,
"CY": 10,
"CZ": 10,
"DK": 10,
"EE": 10,
"FI": 10,
"FR": 10,
"DE": 10,
"GR": 10,
"HU": 10,
"IE": 10,
"IT": 10,
"LV": 10,
"LT": 10,
"LU": 10,
"MT": 10,
"NL": 10,
"PL": 10,
"PT": 10,
"RO": 10,
"SK": 10,
"SI": 10,
"ES": 10,
"SE": 10,
# EEA Members
"IS": 10,
"LI": 10,
"NO": 10,
# Standards harmonized with RED or ETSI
"CH": 10,
"GB": 10,
"TR": 10,
"AL": 10,
"BA": 10,
"GE": 10,
"MD": 10,
"ME": 10,
"MK": 10,
"RS": 10,
"UA": 10,
# Other CEPT nations
"AD": 10,
"AZ": 10,
"MC": 10,
"SM": 10,
"VA": 10,
}
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
OptionsFlow, OptionsFlow,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType _picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -97,6 +98,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_uninstall_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task[None] | None = None self.firmware_install_task: asyncio.Task[None] | None = None
self.installing_firmware_name: str | None = None self.installing_firmware_name: str | None = None
self._install_otbr_addon_task: asyncio.Task[None] | None = None
self._start_otbr_addon_task: asyncio.Task[None] | None = None
# Progress flow steps cannot abort so we need to store the abort reason and then
# re-raise it in a dedicated step
self._progress_error: AbortFlow | None = None
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
@@ -106,6 +113,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._probed_firmware_info is not None if self._probed_firmware_info is not None
else "unknown" else "unknown"
), ),
"firmware_name": (
self.installing_firmware_name
if self.installing_firmware_name is not None
else "unknown"
),
"model": self._hardware_name, "model": self._hardware_name,
} }
@@ -182,22 +194,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress( return self.async_show_progress(
step_id=step_id, step_id=step_id,
progress_action="install_firmware", progress_action="install_firmware",
description_placeholders={ description_placeholders=self._get_translation_placeholders(),
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task, progress_task=self.firmware_install_task,
) )
try: try:
await self.firmware_install_task await self.firmware_install_task
except AbortFlow as err: except AbortFlow as err:
return self.async_show_progress_done( self._progress_error = err
next_step_id=err.reason, return self.async_show_progress_done(next_step_id="progress_failed")
)
except HomeAssistantError: except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware") _LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed") self._progress_error = AbortFlow(
reason="fw_install_failed",
description_placeholders=self._get_translation_placeholders(),
)
return self.async_show_progress_done(next_step_id="progress_failed")
finally: finally:
self.firmware_install_task = None self.firmware_install_task = None
@@ -219,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is # Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type # installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality. # isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device) self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or ( firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type self._probed_firmware_info.firmware_type != expected_installed_firmware_type
@@ -241,7 +257,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("Skipping firmware upgrade due to index download failure") _LOGGER.debug("Skipping firmware upgrade due to index download failure")
return return
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
if not firmware_install_required: if not firmware_install_required:
assert self._probed_firmware_info is not None assert self._probed_firmware_info is not None
@@ -270,7 +289,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return return
# Otherwise, fail # Otherwise, fail
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
self._probed_firmware_info = await async_flash_silabs_firmware( self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass, hass=self.hass,
@@ -278,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type, expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress( progress_callback=lambda offset, total: self.async_update_progress(
offset / total offset / total
), ),
@@ -313,41 +336,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
await otbr_manager.async_start_addon_waiting() await otbr_manager.async_start_addon_waiting()
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware download failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_firmware_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware install failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_unsupported_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when unsupported firmware is detected."""
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_zigbee_installation_type( async def async_step_zigbee_installation_type(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -456,6 +444,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# This step is necessary to prevent `user_input` from being passed through # This step is necessary to prevent `user_input` from being passed through
return await self.async_step_continue_zigbee() return await self.async_step_continue_zigbee()
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
return {}
async def async_step_continue_zigbee( async def async_step_continue_zigbee(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -478,6 +470,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
"radio_type": "ezsp", "radio_type": "ezsp",
"flow_strategy": self._zigbee_flow_strategy, "flow_strategy": self._zigbee_flow_strategy,
**self._extra_zha_hardware_options(),
}, },
) )
return self._continue_zha_flow(result) return self._continue_zha_flow(result)
@@ -506,16 +499,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware.""" """Install Thread firmware."""
raise NotImplementedError raise NotImplementedError
@progress_step( async def async_step_progress_failed(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon.""" """Abort when progress step failed."""
assert self._progress_error is not None
raise self._progress_error
async def _async_install_otbr_addon(self) -> None:
"""Do the work of installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass) addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager) addon_info = await self._async_get_addon_info(addon_manager)
@@ -533,18 +525,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_finish_thread_installation() async def async_step_install_otbr_addon(
@progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon.""" """Show progress dialog for installing the OTBR addon."""
if self._install_otbr_addon_task is None:
self._install_otbr_addon_task = self.hass.async_create_task(
self._async_install_otbr_addon(),
"Install OTBR addon",
)
if not self._install_otbr_addon_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
progress_action="install_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._install_otbr_addon_task,
)
try:
await self._install_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._install_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="finish_thread_installation")
async def _async_start_otbr_addon(self) -> None:
"""Do the work of starting the OTBR addon."""
try: try:
await self._configure_and_start_otbr_addon() await self._configure_and_start_otbr_addon()
except AddonError as err: except AddonError as err:
@@ -557,7 +570,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_pre_confirm_otbr() async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
if self._start_otbr_addon_task is None:
self._start_otbr_addon_task = self.hass.async_create_task(
self._async_start_otbr_addon(),
"Start OTBR addon",
)
if not self._start_otbr_addon_task.done():
return self.async_show_progress(
step_id="start_otbr_addon",
progress_action="start_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._start_otbr_addon_task,
)
try:
await self._start_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._start_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr( async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import ( from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS, KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401 KEY_AUTHENTICATED, # noqa: F401
@@ -109,7 +110,7 @@ HTTP_SCHEMA: Final = vol.All(
cv.deprecated(CONF_BASE_URL), cv.deprecated(CONF_BASE_URL),
vol.Schema( vol.Schema(
{ {
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All( vol.Optional(CONF_SERVER_HOST): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string] cv.ensure_list, vol.Length(min=1), [cv.string]
), ),
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
@@ -207,7 +208,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if conf is None: if conf is None:
conf = cast(ConfData, HTTP_SCHEMA({})) conf = cast(ConfData, HTTP_SCHEMA({}))
server_host = conf[CONF_SERVER_HOST] if CONF_SERVER_HOST in conf and is_hassio(hass):
ir.async_create_issue(
hass,
DOMAIN,
"server_host_may_break_hassio",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="server_host_may_break_hassio",
)
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
server_port = conf[CONF_SERVER_PORT] server_port = conf[CONF_SERVER_PORT]
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)

View File

@@ -1,5 +1,9 @@
{ {
"issues": { "issues": {
"server_host_may_break_hassio": {
"description": "The `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
},
"ssl_configured_without_configured_urls": { "ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.", "description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL" "title": "SSL is configured without an external URL or internal URL"

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity.""" """Initialize AutomowerEntity."""
super().__init__(coordinator) super().__init__(coordinator)
self.mower_id = mower_id self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2) model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
"Husqvarna "
).removeprefix("HUSQVARNA ")
parts = model_witout_manufacturer.split(maxsplit=1)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)}, identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0], manufacturer="Husqvarna",
model=parts[1], model=parts[0].capitalize().removesuffix("®"),
model_id=parts[2], model_id=parts[1],
name=self.mower_attributes.system.name, name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number, serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden", suggested_area="Garden",

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aioautomower==2.7.0"] "requirements": ["aioautomower==2.7.1"]
} }

View File

@@ -112,6 +112,7 @@ async def async_setup_entry(
update_method=async_update_data, update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers. # Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(hours=1), update_interval=timedelta(hours=1),
config_entry=entry,
) )
# Fetch initial data so we have data when entities subscribe # Fetch initial data so we have data when entities subscribe

View File

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

View File

@@ -12,6 +12,7 @@ from pyicloud.exceptions import (
PyiCloudFailedLoginException, PyiCloudFailedLoginException,
PyiCloudNoDevicesException, PyiCloudNoDevicesException,
PyiCloudServiceNotActivatedException, PyiCloudServiceNotActivatedException,
PyiCloudServiceUnavailable,
) )
from pyicloud.services.findmyiphone import AppleDevice from pyicloud.services.findmyiphone import AppleDevice
@@ -130,15 +131,21 @@ class IcloudAccount:
except ( except (
PyiCloudServiceNotActivatedException, PyiCloudServiceNotActivatedException,
PyiCloudNoDevicesException, PyiCloudNoDevicesException,
PyiCloudServiceUnavailable,
) as err: ) as err:
_LOGGER.error("No iCloud device found") _LOGGER.error("No iCloud device found")
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" if user_info is None:
raise ConfigEntryNotReady("No user info found in iCloud devices response")
self._owner_fullname = (
f"{user_info.get('firstName')} {user_info.get('lastName')}"
)
self._family_members_fullname = {} self._family_members_fullname = {}
if user_info.get("membersInfo") is not None: if user_info.get("membersInfo") is not None:
for prs_id, member in user_info["membersInfo"].items(): for prs_id, member in user_info.get("membersInfo").items():
self._family_members_fullname[prs_id] = ( self._family_members_fullname[prs_id] = (
f"{member['firstName']} {member['lastName']}" f"{member['firstName']} {member['lastName']}"
) )

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/icloud", "documentation": "https://www.home-assistant.io/integrations/icloud",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"], "loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.1.0"] "requirements": ["pyicloud==2.2.0"]
} }

View File

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

View File

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

View File

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

View File

@@ -299,8 +299,8 @@ def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClim
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE), group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE), group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
sync_state=sync_state, sync_state=sync_state,
min_temp=conf.get(ClimateConf.MIN_TEMP), min_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MIN_TEMP),
max_temp=conf.get(ClimateConf.MAX_TEMP), max_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MAX_TEMP),
mode=climate_mode, mode=climate_mode,
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED), group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED), group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
@@ -486,7 +486,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase):
ha_controller_modes.append(self._last_hvac_mode) ha_controller_modes.append(self._last_hvac_mode)
ha_controller_modes.append(HVACMode.OFF) ha_controller_modes.append(HVACMode.OFF)
hvac_modes = list(set(filter(None, ha_controller_modes))) hvac_modes = sorted(set(filter(None, ha_controller_modes)))
return ( return (
hvac_modes hvac_modes
if hvac_modes if hvac_modes

View File

@@ -11,9 +11,9 @@
"loggers": ["xknx", "xknxproject"], "loggers": ["xknx", "xknxproject"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": [ "requirements": [
"xknx==3.10.0", "xknx==3.10.1",
"xknxproject==3.8.2", "xknxproject==3.8.2",
"knx-frontend==2025.10.26.81530" "knx-frontend==2025.10.31.195356"
], ],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
write=False, state_required=True, valid_dpt="9.001" write=False, state_required=True, valid_dpt="9.001"
), ),
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector( vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
write=False, valid_dpt="9.002" write=False, valid_dpt="9.007"
), ),
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect( vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
GroupSelectOption( GroupSelectOption(

View File

@@ -221,7 +221,7 @@ async def library_payload(hass):
for child in library_info.children: for child in library_info.children:
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png" child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
with contextlib.suppress(media_source.BrowseError): with contextlib.suppress(BrowseError):
item = await media_source.async_browse_media( item = await media_source.async_browse_media(
hass, None, content_filter=media_source_content_filter hass, None, content_filter=media_source_content_filter
) )

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
from asyncio import Task
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
_default_update_interval = SCAN_INTERVAL _default_update_interval = SCAN_INTERVAL
config_entry: LaMarzoccoConfigEntry config_entry: LaMarzoccoConfigEntry
websocket_terminated = True _websocket_task: Task | None = None
def __init__( def __init__(
self, self,
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device self.device = device
self.cloud_client = cloud_client self.cloud_client = cloud_client
@property
def websocket_terminated(self) -> bool:
"""Return True if the websocket task is terminated or not running."""
if self._websocket_task is None:
return True
return self._websocket_task.done()
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Do the data update.""" """Do the data update."""
try: try:
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
# ensure token stays valid; does nothing if token is still valid # ensure token stays valid; does nothing if token is still valid
await self.cloud_client.async_get_access_token() await self.cloud_client.async_get_access_token()
if self.device.websocket.connected: # Only skip websocket reconnection if it's currently connected and the task is still running
if self.device.websocket.connected and not self.websocket_terminated:
return return
await self.device.get_dashboard() await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
self.config_entry.async_create_background_task( self._websocket_task = self.config_entry.async_create_background_task(
hass=self.hass, hass=self.hass,
target=self.connect_websocket(), target=self.connect_websocket(),
name="lm_websocket_task", name="lm_websocket_task",
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
_LOGGER.debug("Init WebSocket in background task") _LOGGER.debug("Init WebSocket in background task")
self.websocket_terminated = False
self.async_update_listeners() self.async_update_listeners()
await self.device.connect_dashboard_websocket( await self.device.connect_dashboard_websocket(
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
disconnect_callback=self.async_update_listeners, disconnect_callback=self.async_update_listeners,
) )
self.websocket_terminated = True
self.async_update_listeners() self.async_update_listeners()

View File

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

View File

@@ -622,6 +622,7 @@ ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
usage_period=USAGE_MONTHLY, usage_period=USAGE_MONTHLY,
start_date_fn=lambda today: today, start_date_fn=lambda today: today,
end_date_fn=lambda today: today, end_date_fn=lambda today: today,
state_class=SensorStateClass.TOTAL_INCREASING,
), ),
ThinQEnergySensorEntityDescription( ThinQEnergySensorEntityDescription(
key="last_month", key="last_month",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.4.0"] "requirements": ["librehardwaremonitor-api==1.5.0"]
} }

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.components.sensor import SensorEntity, SensorStateClass
@@ -51,10 +53,10 @@ class LibreHardwareMonitorSensor(
super().__init__(coordinator) super().__init__(coordinator)
self._attr_name: str = sensor_data.name self._attr_name: str = sensor_data.name
self.value: str | None = sensor_data.value self._attr_native_value: str | None = sensor_data.value
self._attr_extra_state_attributes: dict[str, str] = { self._attr_extra_state_attributes: dict[str, Any] = {
STATE_MIN_VALUE: self._format_number_value(sensor_data.min), STATE_MIN_VALUE: sensor_data.min,
STATE_MAX_VALUE: self._format_number_value(sensor_data.max), STATE_MAX_VALUE: sensor_data.max,
} }
self._attr_native_unit_of_measurement = sensor_data.unit self._attr_native_unit_of_measurement = sensor_data.unit
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}" self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
@@ -72,23 +74,12 @@ class LibreHardwareMonitorSensor(
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id): if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
self.value = sensor_data.value self._attr_native_value = sensor_data.value
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
STATE_MIN_VALUE: self._format_number_value(sensor_data.min), STATE_MIN_VALUE: sensor_data.min,
STATE_MAX_VALUE: self._format_number_value(sensor_data.max), STATE_MAX_VALUE: sensor_data.max,
} }
else: else:
self.value = None self._attr_native_value = None
super()._handle_coordinator_update() super()._handle_coordinator_update()
@property
def native_value(self) -> str | None:
"""Return the formatted sensor value or None if no value is available."""
if self.value is not None and self.value != "-":
return self._format_number_value(self.value)
return None
@staticmethod
def _format_number_value(number_str: str) -> str:
return number_str.replace(",", ".")

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylitterbot"], "loggers": ["pylitterbot"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.7"] "requirements": ["pylitterbot==2025.0.0"]
} }

View File

@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
forecasts_by_date[date].append(timestamp) forecasts_by_date[date].append(timestamp)
daily_forecasts = [] daily_forecasts = []
for date in sorted(forecasts_by_date.keys())[:5]: for date in sorted(forecasts_by_date.keys()):
day_forecasts = forecasts_by_date[date] day_forecasts = forecasts_by_date[date]
if not day_forecasts: if not day_forecasts:
continue continue
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
return None return None
return [ return [
self._convert_forecast_data(forecast_data) self._convert_forecast_data(forecast_data)
for forecast_data in self.coordinator.data.forecast_timestamps[:24] for forecast_data in self.coordinator.data.forecast_timestamps
] ]

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Final from typing import Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
self._device_id, self._device_id,
{PROCESS_ACTION: self.entity_description.press_data}, {PROCESS_ACTION: self.entity_description.press_data},
) )
except aiohttp.ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aiohttp from aiohttp import ClientResponseError
from pymiele import MieleDevice, MieleTemperature from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
cast(float, kwargs.get(ATTR_TEMPERATURE)), cast(float, kwargs.get(ATTR_TEMPERATURE)),
self.entity_description.zone, self.entity_description.zone,
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
_LOGGER.debug( _LOGGER.debug(
"Error fetching actions for device %s: Status: %s, Message: %s", "Error fetching actions for device %s: Status: %s, Message: %s",
device_id, device_id,
err.status, str(err.status),
err.message, err.message,
) )
actions_json = {} actions_json = {}

View File

@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {VENTILATION_STEP: ventilation_step} self._device_id, {VENTILATION_STEP: ventilation_step}
) )
except ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err
self.device.state_ventilation_step = ventilation_step self.device.state_ventilation_step = ventilation_step
self.async_write_ha_state() self.async_write_ha_state()
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final from typing import Any, Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.light import ( from homeassistant.components.light import (
ColorMode, ColorMode,
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {self.entity_description.light_type: mode} self._device_id, {self.entity_description.light_type: mode}
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
REVOLUTIONS_PER_MINUTE, REVOLUTIONS_PER_MINUTE,
STATE_UNKNOWN,
EntityCategory, EntityCategory,
UnitOfEnergy, UnitOfEnergy,
UnitOfTemperature, UnitOfTemperature,
@@ -762,40 +761,35 @@ class MieleSensor(MieleEntity, SensorEntity):
class MieleRestorableSensor(MieleSensor, RestoreSensor): class MieleRestorableSensor(MieleSensor, RestoreSensor):
"""Representation of a Sensor whose internal state can be restored.""" """Representation of a Sensor whose internal state can be restored."""
_last_value: StateType _attr_native_value: StateType
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id, description)
self._last_value = None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
# recover last value from cache when adding entity # recover last value from cache when adding entity
last_value = await self.async_get_last_state() last_data = await self.async_get_last_sensor_data()
if last_value and last_value.state != STATE_UNKNOWN: if last_data:
self._last_value = last_value.state self._attr_native_value = last_data.native_value # type: ignore[assignment]
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the state of the sensor.""" """Return the state of the sensor.
return self._last_value
def _update_last_value(self) -> None: It is necessary to override `native_value` to fall back to the default
"""Update the last value of the sensor.""" attribute-based implementation, instead of the function-based
self._last_value = self.entity_description.value_fn(self.device) implementation in `MieleSensor`.
"""
return self._attr_native_value
def _update_native_value(self) -> None:
"""Update the native value attribute of the sensor."""
self._attr_native_value = self.entity_description.value_fn(self.device)
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
self._update_last_value() self._update_native_value()
super()._handle_coordinator_update() super()._handle_coordinator_update()
@@ -912,7 +906,7 @@ class MieleProgramIdSensor(MieleSensor):
class MieleTimeSensor(MieleRestorableSensor): class MieleTimeSensor(MieleRestorableSensor):
"""Representation of time sensors keeping state from cache.""" """Representation of time sensors keeping state from cache."""
def _update_last_value(self) -> None: def _update_native_value(self) -> None:
"""Update the last value of the sensor.""" """Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device) current_value = self.entity_description.value_fn(self.device)
@@ -923,7 +917,9 @@ class MieleTimeSensor(MieleRestorableSensor):
current_status == StateStatus.PROGRAM_ENDED current_status == StateStatus.PROGRAM_ENDED
and self.entity_description.end_value_fn is not None and self.entity_description.end_value_fn is not None
): ):
self._last_value = self.entity_description.end_value_fn(self._last_value) self._attr_native_value = self.entity_description.end_value_fn(
self._attr_native_value
)
# keep value when program ends if no function is specified # keep value when program ends if no function is specified
elif current_status == StateStatus.PROGRAM_ENDED: elif current_status == StateStatus.PROGRAM_ENDED:
@@ -931,11 +927,11 @@ class MieleTimeSensor(MieleRestorableSensor):
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE): elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
self._last_value = None self._attr_native_value = None
# otherwise, cache value and return it # otherwise, cache value and return it
else: else:
self._last_value = current_value self._attr_native_value = current_value
class MieleConsumptionSensor(MieleRestorableSensor): class MieleConsumptionSensor(MieleRestorableSensor):
@@ -943,17 +939,23 @@ class MieleConsumptionSensor(MieleRestorableSensor):
_is_reporting: bool = False _is_reporting: bool = False
def _update_last_value(self) -> None: def _update_native_value(self) -> None:
"""Update the last value of the sensor.""" """Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device) current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status) current_status = StateStatus(self.device.state_status)
# Guard for corrupt restored value
restored_value = (
self._attr_native_value
if isinstance(self._attr_native_value, (int, float))
else 0
)
last_value = ( last_value = (
float(cast(str, self._last_value)) float(cast(str, restored_value))
if self._last_value is not None and self._last_value != STATE_UNKNOWN if self._attr_native_value is not None
else 0 else 0
) )
# force unknown when appliance is not able to report consumption # Force unknown when appliance is not able to report consumption
if current_status in ( if current_status in (
StateStatus.ON, StateStatus.ON,
StateStatus.OFF, StateStatus.OFF,
@@ -963,7 +965,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
StateStatus.SERVICE, StateStatus.SERVICE,
): ):
self._is_reporting = False self._is_reporting = False
self._last_value = None self._attr_native_value = None
# appliance might report the last value for consumption of previous cycle and it will report 0 # appliance might report the last value for consumption of previous cycle and it will report 0
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
@@ -973,7 +975,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
and not self._is_reporting and not self._is_reporting
and last_value > 0 and last_value > 0
): ):
self._last_value = current_value self._attr_native_value = current_value
self._is_reporting = True self._is_reporting = True
elif ( elif (
@@ -982,12 +984,12 @@ class MieleConsumptionSensor(MieleRestorableSensor):
and current_value is not None and current_value is not None
and cast(int, current_value) > 0 and cast(int, current_value) > 0
): ):
self._last_value = 0 self._attr_native_value = 0
# keep value when program ends # keep value when program ends
elif current_status == StateStatus.PROGRAM_ENDED: elif current_status == StateStatus.PROGRAM_ENDED:
pass pass
else: else:
self._last_value = current_value self._attr_native_value = current_value
self._is_reporting = True self._is_reporting = True

View File

@@ -4,7 +4,7 @@ from datetime import timedelta
import logging import logging
from typing import cast from typing import cast
import aiohttp from aiohttp import ClientResponseError
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
data = {"programId": call.data[ATTR_PROGRAM_ID]} data = {"programId": call.data[ATTR_PROGRAM_ID]}
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_error", translation_key="set_program_error",
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
data["temperature"] = call.data[ATTR_TEMPERATURE] data["temperature"] = call.data[ATTR_TEMPERATURE]
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_oven_error", translation_key="set_program_oven_error",
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
try: try:
programs = await api.get_programs(serial_number) programs = await api.get_programs(serial_number)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="get_programs_error", translation_key="get_programs_error",

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