Compare commits

..

293 Commits

Author SHA1 Message Date
Erik Montnemery
b1f943ccda Replace discovery with user flow in Philips Hue BLE (#163924) 2026-02-24 11:06:31 +01:00
Brett Adams
e37d84049a Update Splunk integration to bronze quality scale (#163616)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 10:56:05 +01:00
Marc Mueller
209473e376 Remove myself as codeowner for fritzbox_callmonitor (#163927) 2026-02-24 10:45:58 +01:00
MoonDevLT
334c3af448 Bump lunatone-rest-api-client to 0.7.0 (#163594) 2026-02-24 10:10:04 +01:00
hanwg
5560139d24 Clean up duplicated code in Telegram bot (#163917) 2026-02-24 10:04:21 +01:00
Erik Montnemery
d4dec5d1d3 Improve backup_restore tests (#163921) 2026-02-24 10:03:42 +01:00
J. Nick Koston
6cb63a60bc Skip unknown entity types in ESPHome integration (#163887) 2026-02-24 08:48:27 +01:00
Franck Nijhof
991301e79e Merge branch 'master' into dev 2026-02-24 07:07:39 +00:00
andreimoraru
06e2b4633a Bump yt-dlp to 2026.2.21 (#163916) 2026-02-24 07:30:54 +01:00
Manu
048d8d217c Update strings in ntfy integration (#163912) 2026-02-24 06:24:18 +01:00
Kyle Johnson
3693bc5878 Make Google Assistant fan speed percent and step speeds mutually exclusive (#162770) 2026-02-23 22:26:09 +00:00
Denis Shulyaka
af9ea5ea7a Bump anthropic to 0.83.0 (#163899) 2026-02-23 21:43:07 +00:00
Robert Resch
977d29956b Add clean_area support for Ecovacs mqtt vacuums (#163580) 2026-02-23 22:42:25 +01:00
Jamie Magee
fc9bdb3cb1 Bring aladdin_connect to Bronze quality scale (#163221)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-23 22:16:51 +01:00
Erwin Douna
bb1956c738 Portainer Platinum score (#163898) 2026-02-23 22:15:59 +01:00
J. Nick Koston
9212279c2c Bump aioesphomeapi 44.1.0 (#163894) 2026-02-23 22:14:40 +01:00
Denis Shulyaka
7e162cfda2 Update Anthropic models (#163897) 2026-02-23 22:13:31 +01:00
Tom Matheussen
5611b4564f Add debounce to Satel Integra alarm panel state (#163602) 2026-02-23 21:57:39 +01:00
Manu
1a16674f86 Update quality scale of Xbox integration to platinum 🏆️ (#155577)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:56:05 +01:00
Paul Tarjan
bae4de3753 Add Hikvision integration quality scale (#159252)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:53:22 +01:00
mettolen
8f2bfa1bb0 Add select entities to Liebherr integration (#163581) 2026-02-23 21:52:50 +01:00
Manu
fb118ed516 Add support for action buttons to ntfy integration (#152014) 2026-02-23 21:46:00 +01:00
Markus Adrario
bea84151b1 homee: add one-button-remote to event platform (#163690) 2026-02-23 21:42:08 +01:00
Markus Adrario
d581d65c8b Add handling of 2 IP addresses to homee (#162731)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:36:49 +01:00
Erwin Douna
bc1837d09d Portainer gold standard review (#155231)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 21:34:06 +01:00
Daniel Hjelseth Høyer
9cc3c850aa Homevolt switch platform (#163415)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-23 21:16:43 +01:00
Markus
8927960fca fix(snapcast): do not crash when stream is not found (#162439)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 21:09:14 +01:00
Erwin Douna
49b8232260 Add stale device removal to portainer (#160017)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:05:52 +01:00
Barry vd. Heuvel
1d5e8a9e5a Weheat energy logs update (#163621)
Co-authored-by: Jesper Raemaekers <jesper.raemaekers@wefabricate.com>
2026-02-23 21:00:35 +01:00
dvdinth
501e095578 Add IntelliClima Select platform (#163637)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-23 20:41:41 +01:00
Jeef
dc5eab6810 Allow support of Graph QL 4.0 / Bump pytibber 0.36.0 (#163305)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 20:41:05 +01:00
Manu
25787d2b75 Add DeviceInfo to Google Translate (#163762) 2026-02-23 20:29:49 +01:00
Denis Shulyaka
e57613af65 Anthropic interleaved thinking (#163583)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 20:24:40 +01:00
Erwin Douna
89ff86a941 Add diagnostics to Proxmox (#163800)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-23 20:17:49 +01:00
Brett Adams
c62ceee8fc Update Teslemetry quality scale to silver (#163611)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-23 20:12:38 +01:00
J. Nick Koston
d732e3d5ae Add climate platform to Trane Local integration (#163571)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:03:08 +01:00
Denis Shulyaka
dd78da929e Improve config flow tests for Anthropic (#163757) 2026-02-23 19:15:46 +01:00
Christopher Fenner
c2b74b7612 Correct EnOcean integration type (#163725) 2026-02-23 19:11:12 +01:00
Tom
6570b413d4 Add discovery for airOS devices (#154568)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 18:59:50 +01:00
Christian Lackas
ea7732e9ee Add heat pump sensors to ViCare integration (#161422) 2026-02-23 18:54:12 +01:00
TheJulianJES
4c885e7ce8 Fix ZHA number entity not using device class and mode (#163827) 2026-02-23 18:53:58 +01:00
Christian Lackas
67395f1cf5 Handle PyViCare device communication and server errors in ViCare integration (#162618) 2026-02-23 18:53:00 +01:00
Joost Lekkerkerker
a552266bfc Bump python-overseerr to 0.9.0 (#163883) 2026-02-23 18:52:56 +01:00
Bouwe Westerdijk
e6c2d54232 Improve Plugwise set_hvac_mode() logic (#163713) 2026-02-23 18:52:29 +01:00
Willem-Jan van Rootselaar
994eae8412 Bump python-bsblan to 5.0.1 (#163840) 2026-02-23 18:50:49 +01:00
Abílio Costa
b712207b75 Add refrigerator temperature level select to whirlpool (#162110)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 18:45:48 +01:00
wollew
fa38f25d4f Enable strict typing in Velux integration (#163798) 2026-02-23 18:05:50 +01:00
Karl Beecken
3a27fa782e Teltonika quality scale: mark test-coverage done (#163707) 2026-02-23 18:03:11 +01:00
Nathan Spencer
ffeb759aba Rename Litter-Robot integration to Whisker (#163826) 2026-02-23 17:46:15 +01:00
Denis Shulyaka
e96da42996 Fix notification service exceptions fot Telegram bot (#163882) 2026-02-23 17:40:22 +01:00
Tom
ce71e540ae Add airOS device reboot button (#163718)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 17:37:24 +01:00
Steve Easley
9b2bcaed92 Bump Kaleidescape integration dependency to v1.1.3 (#163884) 2026-02-23 17:36:44 +01:00
Ludovic BOUÉ
f564ad3ebe Add Matter KNX bridge fixture (#163875) 2026-02-23 17:30:51 +01:00
Joost Lekkerkerker
bd1b060718 Add integration_type device to solarlog (#163628) 2026-02-23 17:26:26 +01:00
Willem-Jan van Rootselaar
f4cab72228 Minor type fixes (#163606) 2026-02-23 17:26:07 +01:00
Leo Periou
733d381a7c Add new MyNeomitis integration (#151377)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 17:14:30 +01:00
Ingo Fischer
6fba886edb Replace Matter python client (#163704) 2026-02-23 17:02:39 +01:00
epenet
2f95d1ef78 Mark lock entity type hints as mandatory (#163796) 2026-02-23 16:50:52 +01:00
jesperraemaekers
6d6727ed58 Change weheat codeowner (#163860) 2026-02-23 16:49:41 +01:00
epenet
9c0c9758f0 Mark light entity type hints as mandatory (#163794) 2026-02-23 16:48:30 +01:00
epenet
bfa2da32fc Mark geo_location entity type hints as mandatory (#163790) 2026-02-23 16:48:12 +01:00
Paul Bottein
dfb17c2187 Add configurable panel properties to frontend (#162742)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-23 16:15:44 +01:00
Klaas Schoute
ac65163ebb Bump forecast-solar to v5.0.0 (#163841) 2026-02-23 15:58:54 +01:00
Sab44
f3042741bf Deprecate Libre Hardware Monitor versions below v0.9.5 (#163838) 2026-02-23 15:57:17 +01:00
Joost Lekkerkerker
80936497ce Add Zinvolt integration (#163449) 2026-02-23 15:55:15 +01:00
Joost Lekkerkerker
5e3d2bec68 Add integration_type device to sia (#163393) 2026-02-23 15:18:54 +01:00
Michael
e1667bd5c6 Increase request timeout from 10 to 20s in FRITZ!SmartHome (#163818) 2026-02-23 15:02:10 +01:00
Ludovic BOUÉ
cdb92a54b0 Fix Matter speaker mute toggle (#161128)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 14:38:37 +01:00
Erik Montnemery
74a3f4bbb9 Bump securetar to 2026.2.0 (#163226) 2026-02-23 14:03:43 +01:00
Nic Eggert
6299e8cb77 Add support for current sensors to egauge integration (#163728) 2026-02-23 13:29:20 +01:00
Joost Lekkerkerker
0f6a3a8328 Add integration_type service to snapcast (#163401) 2026-02-23 13:17:31 +01:00
Joost Lekkerkerker
77a56a3e60 Add integration_type device to smart_meter_texas (#163398) 2026-02-23 13:17:02 +01:00
Joost Lekkerkerker
cf5733de97 Add integration_type device to tilt_pi (#163667) 2026-02-23 13:16:37 +01:00
Joost Lekkerkerker
fe377befa6 Add integration_type hub to wallbox (#163752) 2026-02-23 13:12:40 +01:00
Joost Lekkerkerker
9d54236f7d Add integration_type hub to waqi (#163754) 2026-02-23 13:12:11 +01:00
Karl Beecken
bd6b8a812c Teltonika integration: add reauth config flow (#163712) 2026-02-23 13:07:19 +01:00
kshypachov
85eeac6812 Fix Matter energy sensor discovery when value is null (#162044)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-02-23 11:52:05 +01:00
Robert Resch
ea71c40b0a Bump deebot-client to 18.0.0 (#163835) 2026-02-23 11:45:55 +01:00
Ludovic BOUÉ
99bd66194d Add allow_none_value=True to MatterDiscoverySchema for electrical power attributes (#163195)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-02-23 11:33:57 +01:00
Sab44
13737ff2e6 Bump librehardwaremonitor-api to version 1.10.1 (#163572) 2026-02-23 11:01:58 +01:00
Tom
55c1d52310 Bump airOS to 0.6.4 (#163716) 2026-02-23 09:12:45 +01:00
hanwg
d5ef379caf Refactoring for Telegram bot (#163767) 2026-02-23 08:35:39 +01:00
Ludovic BOUÉ
a5d59decef Ikea bilresa dual button fixture (#163781)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 08:33:52 +01:00
Nathan Spencer
c75c5c0773 Adjust buttons to support new Litter-Robot lineup (#163825) 2026-02-23 08:32:33 +01:00
Nathan Spencer
f1fc6d10ad Adjust selects to support new Litter-Robot lineup (#163824) 2026-02-23 08:31:59 +01:00
Nathan Spencer
c3376df227 Adjust sensors to support new Litter-Robot lineup (#163823) 2026-02-23 08:30:46 +01:00
epenet
463003fc33 Add test for Tuya event (#163812) 2026-02-23 08:28:23 +01:00
Michael
b9b45c9994 Bump pyfritzhome to 0.6.20 (#163817) 2026-02-23 08:25:35 +01:00
Sebastiaan Speck
eed3b9fb89 Bump renault-api to 0.5.5 (#163821) 2026-02-23 07:35:37 +01:00
Erwin Douna
88d7954d7c Typing fix for Proxmox coordinator (#163808) 2026-02-23 06:58:43 +01:00
andarotajo
ce2afd85d4 Remove myself as code owner from dwd_weather_warnings (#163810) 2026-02-23 06:54:59 +01:00
Raphael Hehl
be96606b2c Bump uiprotect to 10.2.1 (#163816) 2026-02-23 01:05:23 +01:00
Maciej Bieniek
5afad9cabc Use async_add_executor_job in Fitbit to prevent event loop blocking (#163815) 2026-02-22 22:35:12 +01:00
epenet
19b606841d Mark fan entity type hints as mandatory (#163789) 2026-02-22 21:44:53 +01:00
Maciej Bieniek
abdd51c266 Allow unit of measurement translation in Analytics Insights (#163811) 2026-02-22 20:56:27 +01:00
Norbert Rittel
959bafe78b Fix grammar of amcrest.ptz_control action description (#163802) 2026-02-22 19:47:13 +01:00
Raphael Hehl
383f9c203d Unifiprotect ptz support (#161353)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-02-22 10:48:22 -06:00
Harry Heymann
b5d8c1e893 Require product_id for Inovelli LED intensity Matter Number entities (#163680) 2026-02-22 17:47:59 +01:00
epenet
11edd214a1 Improve type hints in igloohome lock (#163795) 2026-02-22 17:13:14 +01:00
Norbert Rittel
15d0241158 Replace "add-on" with "app" in zwave_me (user-facing strings only) (#163703) 2026-02-22 17:12:27 +01:00
Norbert Rittel
309b439744 Replace "add-on" with "app" in recorder (#163714) 2026-02-22 17:11:00 +01:00
Norbert Rittel
49f7c24601 Replace "add-on" with "app" in homeassistant_yellow (#163715) 2026-02-22 17:10:27 +01:00
Ludovic BOUÉ
9f25b4702d Remove CumulativeEnergyExported in fixtures where not needed (#163775) 2026-02-22 17:09:49 +01:00
epenet
a312f9f5bc Improve type hints in lights (#163792) 2026-02-22 17:08:42 +01:00
Marc Mueller
d767a1ca65 Update pillow to 12.1.1 (#163773) 2026-02-22 10:06:08 -06:00
Marc Mueller
d04fb59d56 Update sqlparse to 0.5.5 (#163774) 2026-02-22 10:05:45 -06:00
Marc Mueller
00e441b90d Update pylint to 4.0.5 (#163777) 2026-02-22 10:05:20 -06:00
Aidan Timson
e1fd60aa18 Bump systembridgeconnector to 5.4.3 (#163784) 2026-02-22 10:04:46 -06:00
Luke Lashley
8c41e21b7f Bump python-robroock to 4.17.1 (#163765)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-02-22 07:44:29 -08:00
Ludovic BOUÉ
b7fd1276aa Roborock: Q7 Model Split and Refactor (#163769)
Co-authored-by: Luke Lashley <conway220@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-22 07:32:11 -08:00
David Bonnes
12d06e80ad Rename evohome's test_evo_services.py to test_services.py (#163731) 2026-02-22 14:10:59 +01:00
Simone Chemelli
a377907fd6 Buomp aiovodafone to 3.1.2 (#163779) 2026-02-22 13:58:05 +01:00
Joost Lekkerkerker
16f4f5d54f Add integration_type device to volumio (#163751) 2026-02-22 10:52:43 +01:00
Joost Lekkerkerker
70585d1e23 Add integration_type device to vilfo (#163748) 2026-02-22 10:51:19 +01:00
Joost Lekkerkerker
2f82c3127d Add integration_type device to venstar (#163745) 2026-02-22 10:50:30 +01:00
Joost Lekkerkerker
af4d9cfac8 Add integration_type hub to vera (#163747) 2026-02-22 10:49:00 +01:00
Joost Lekkerkerker
a9abeb6ca5 Add integration_type device to v2c (#163742) 2026-02-22 10:48:24 +01:00
Joost Lekkerkerker
539ad6bf2b Add integration_type hub to uhoo (#163737) 2026-02-22 10:47:59 +01:00
Joost Lekkerkerker
f3e5cf0e56 Add integration_type device to twinkly (#163735) 2026-02-22 10:47:14 +01:00
Joost Lekkerkerker
d4e40b77cf Add integration_type hub to vegehub (#163744) 2026-02-22 10:46:02 +01:00
Joost Lekkerkerker
953391d9d9 Add integration_type service to uptimerobot (#163741) 2026-02-22 10:45:43 +01:00
Joost Lekkerkerker
4f7edb3c3c Add integration_type service to upcloud (#163740) 2026-02-22 10:45:16 +01:00
Joost Lekkerkerker
6aa4b9cefb Add integration_type service to ukraine_alarm (#163738) 2026-02-22 10:44:52 +01:00
Joost Lekkerkerker
ca01cf1150 Add integration_type service to twilio (#163734) 2026-02-22 10:44:29 +01:00
Joost Lekkerkerker
93ed79008b Add integration_type service to twitch (#163736) 2026-02-22 10:44:08 +01:00
Luke Lashley
429249f3f0 Add support for clean_area to Roborock V1 vacuums (#163760)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 19:41:20 -08:00
Joost Lekkerkerker
4fc627a7d8 Add integration_type service to vlc_telnet (#163750) 2026-02-22 00:04:43 +00:00
Joost Lekkerkerker
7c954e9997 Add integration_type device to vivotek (#163749) 2026-02-22 00:00:24 +00:00
Joost Lekkerkerker
95f89df6f4 Add integration_type device to vallox (#163743) 2026-02-21 23:59:53 +00:00
Tim Laing
5bffc14574 Bump pyicloud version to 2.4.1 in manifest and requirements files (#163722) 2026-02-21 23:55:45 +00:00
Ludovic BOUÉ
0e439583a6 Bump python-roborock to 4.15.0 in manifest and requirements files (#163719) 2026-02-21 09:43:06 -08:00
Christopher Fenner
666f6577e6 Bump PyViCare to 2.58.0 (#163686) 2026-02-21 18:09:28 +01:00
Andreas Jakl
ae9f2e6046 NRGkick integration: add reauth config flow (#163619) 2026-02-21 16:43:38 +01:00
Abílio Costa
9b4d209361 Add translated reasons to Govee Light Local setup failures (#163576) 2026-02-21 13:55:34 +01:00
Erwin Douna
99ca425ad0 Bump pyportainer 1.0.28 (#163700) 2026-02-21 11:53:03 +01:00
Josef Zweck
452b0775ee Revert "Replace "add-on" with "app" in zwave_me" (#163701) 2026-02-21 11:13:23 +01:00
Norbert Rittel
dc5caf307b Replace "add-on" with "app" in zwave_me (#163698) 2026-02-21 11:04:45 +01:00
Norbert Rittel
686bcb3199 Replace "add-on" with "app" in homeassistant_hardware (#163696) 2026-02-21 11:04:17 +01:00
hanwg
047d5735d8 Cleanup error handling for Telegram bot (#163689) 2026-02-21 09:19:06 +01:00
Nathan Spencer
c3b0f7ba55 Bump pylitterbot to 2025.1.0 (#163691) 2026-02-21 09:17:59 +01:00
Manu
11f0cd690e Bump aiontfy to 0.8.0 (#163693) 2026-02-21 09:16:14 +01:00
Norbert Rittel
048fbba36d Replace "add-on" with "app" in matter (#163695) 2026-02-21 08:54:49 +01:00
epenet
a791797a6f Mark entity icon type hints as mandatory (#163617) 2026-02-20 22:48:56 +01:00
Franck Nijhof
9c640fe0fa 2026.2.3 (#163683) 2026-02-20 21:43:32 +01:00
Erwin Douna
aa2bb44f0e Bump pyportainer 1.0.27 (#163613)
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2026-02-20 21:33:13 +01:00
Sid
62145e5f9e Bump eheimdigital to 1.6.0 (#161961) 2026-02-20 19:51:10 +00:00
Franck Nijhof
c0fc414bb9 Fix nrgkick tests for rc 2026-02-20 19:49:27 +00:00
Franck Nijhof
69411a05ff Bump version to 2026.2.3 2026-02-20 19:39:05 +00:00
Marc Mueller
06c9ec861d Fix hassfest requirements check (#163681) 2026-02-20 19:38:58 +00:00
Joost Lekkerkerker
946df1755f Bump pySmartThings to 3.5.3 (#163375)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-20 19:38:56 +00:00
Thomas Sejr Madsen
d0678e0641 Fix touchline_sl zone availability when alarm state is set (#163338) 2026-02-20 19:38:55 +00:00
Allen Porter
ec56f183da Bump pyrainbird to 6.0.5 (#163333) 2026-02-20 19:38:53 +00:00
Åke Strandberg
033005e0de Add Miele dishwasher program code (#163308) 2026-02-20 19:38:52 +00:00
Andreas Jakl
91f9f5a826 NRGkick: do not update vehicle connected timestamp when vehicle is not connected (#163292) 2026-02-20 19:38:51 +00:00
David Recordon
ac4fcab827 Fix Control4 HVAC action mapping for multi-stage and idle states (#163222) 2026-02-20 19:38:49 +00:00
Allen Porter
d0eea77178 Fix remote calendar event handling of events within the same update period (#163186)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 19:38:48 +00:00
Markus Adrario
fb38fa3844 Add Lux to homee units (#163180) 2026-02-20 19:38:47 +00:00
Allen Porter
440efb953e Bump ical to 13.2.0 (#163123) 2026-02-20 19:38:45 +00:00
Manu
7ce47cca0d Fix blocking call in Xbox config flow (#163122) 2026-02-20 19:38:44 +00:00
Andre Lengwenus
a5f607bb91 Bump pypck to 0.9.11 (#163043) 2026-02-20 19:38:42 +00:00
Andre Lengwenus
b03043aa6f Bump pypck to 0.9.10 (#162333) 2026-02-20 19:38:41 +00:00
Robert Resch
0f3c7ca277 Block redirect to localhost (#162941) 2026-02-20 19:37:03 +00:00
Martin Hjelmare
3abf7c22f3 Fix Z-Wave climate set preset (#162728) 2026-02-20 19:37:01 +00:00
hbludworth
292e1de126 Show progress indicator during backup stage of Core/App update (#162683) 2026-02-20 19:37:00 +00:00
Christian Lackas
2d776a8193 Fix HomematicIP entity recovery after access point cloud reconnect (#162575) 2026-02-20 19:36:58 +00:00
Sid
039bbbb48c Fix dynamic entity creation in eheimdigital (#161155) 2026-02-20 19:36:56 +00:00
Luke Lashley
ad5565df95 Add the ability to select region for Roborock (#160898) 2026-02-20 19:36:55 +00:00
Marc Mueller
6ecbaa979a Fix hassfest requirements check (#163681) 2026-02-20 20:36:07 +01:00
epenet
6115a4c1fb Use shorthand attributes in swiss_hydrological_data (#163607) 2026-02-20 19:56:39 +01:00
Joost Lekkerkerker
f6459453ed Add integration_type hub to surepetcare (#163646) 2026-02-20 19:51:02 +01:00
epenet
eeb7ce3725 Improve type hints in homematic hub (#163614) 2026-02-20 19:49:23 +01:00
Joost Lekkerkerker
f020948e2d Add integration_type hub to tradfri (#163673)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-20 19:48:55 +01:00
Joost Lekkerkerker
0711176f9c Add integration_type device to tilt_ble (#163666) 2026-02-20 19:48:35 +01:00
Joost Lekkerkerker
cd26901386 Add integration_type service to todoist (#163668) 2026-02-20 19:46:51 +01:00
Joost Lekkerkerker
3c1b7ada9a Add integration_type device to tolo (#163670) 2026-02-20 19:46:36 +01:00
Joost Lekkerkerker
debf07e3fc Add integration_type device to toon (#163671) 2026-02-20 19:46:07 +01:00
Joost Lekkerkerker
541cc808b0 Add integration_type hub to totalconnect (#163672) 2026-02-20 19:45:21 +01:00
Joost Lekkerkerker
46b0eaecf6 Add integration_type service to trafikverket_camera (#163674) 2026-02-20 19:43:48 +01:00
Joost Lekkerkerker
35e770b998 Add integration_type service to trafikverket_ferry (#163675) 2026-02-20 19:43:14 +01:00
Joost Lekkerkerker
ed9ad950d9 Add integration_type service to trafikverket_train (#163676) 2026-02-20 19:42:55 +01:00
Joost Lekkerkerker
02058afb10 Add integration_type service to trafikverket_weatherstation (#163677) 2026-02-20 19:42:39 +01:00
Joost Lekkerkerker
3f6bfa96fc Add integration_type hub to tellduslive (#163661) 2026-02-20 19:41:34 +01:00
Joost Lekkerkerker
430f064243 Add integration_type device to tesla_wall_connector (#163662) 2026-02-20 19:40:20 +01:00
Joost Lekkerkerker
08adb88c6b Add integration_type device to thermobeacon (#163663) 2026-02-20 19:39:43 +01:00
Joost Lekkerkerker
14b6269dbf Add integration_type device to thermopro (#163664) 2026-02-20 19:39:05 +01:00
Joost Lekkerkerker
19b1fc6561 Add integration_type hub to tibber (#163665) 2026-02-20 19:37:34 +01:00
Joost Lekkerkerker
b6e83d22e3 Add integration_type device to syncthru (#163658) 2026-02-20 19:36:19 +01:00
Joost Lekkerkerker
7cd48ef079 Add integration_type device to tami4 (#163659) 2026-02-20 19:35:29 +01:00
Joost Lekkerkerker
2a03d95bcd Add integration_type service to telegram_bot (#163660) 2026-02-20 19:34:39 +01:00
Joost Lekkerkerker
e7e8c7a53a Add integration_type device to togrill (#163669) 2026-02-20 19:11:57 +01:00
Joost Lekkerkerker
6ce28987ab Add integration_type service to syncthing (#163651) 2026-02-20 16:27:23 +01:00
Joost Lekkerkerker
da537ddb8b Add integration_type device to steamist (#163640) 2026-02-20 16:26:59 +01:00
Joost Lekkerkerker
03f81e4a09 Add integration_type hub to starline (#163638) 2026-02-20 16:25:58 +01:00
Joost Lekkerkerker
88bc6165b5 Add integration_type device to starlink (#163639) 2026-02-20 16:25:33 +01:00
Joost Lekkerkerker
a1f35ed3c4 Add integration_type hub to switcher_kis (#163650) 2026-02-20 17:23:57 +02:00
Joost Lekkerkerker
c15a804ab4 Add integration_type service to srp_energy (#163636) 2026-02-20 16:23:39 +01:00
Joost Lekkerkerker
34f1c4cbe0 Add integration_type device to soundtouch (#163634) 2026-02-20 16:23:00 +01:00
Joost Lekkerkerker
bf950e4916 Add integration_type service to splunk (#163635) 2026-02-20 16:22:33 +01:00
Joost Lekkerkerker
47eba50b4a Add integration_type service to sonarr (#163632) 2026-02-20 16:22:07 +01:00
Joost Lekkerkerker
8ff06f3c72 Add integration_type hub to soma (#163630) 2026-02-20 16:21:35 +01:00
Joost Lekkerkerker
d2918586f9 Add integration_type device to solax (#163629) 2026-02-20 16:21:09 +01:00
Joost Lekkerkerker
8c3e72b53d Add integration_type device to snooz (#163627) 2026-02-20 16:20:31 +01:00
Joost Lekkerkerker
3143d9c4fd Add integration_type hub to snoo (#163626) 2026-02-20 16:20:01 +01:00
Joost Lekkerkerker
04621a2e58 Add integration_type hub to switchbee (#163648) 2026-02-20 16:19:28 +01:00
Joost Lekkerkerker
9b6e6a688d Add integration_type service to swiss_public_transport (#163647) 2026-02-20 16:18:57 +01:00
Joost Lekkerkerker
2bf5f67ecd Add integration_type service to suez_water (#163644) 2026-02-20 16:18:20 +01:00
Joost Lekkerkerker
522f63cdab Add integration_type hub to sunricher_dali (#163645) 2026-02-20 16:18:03 +01:00
Joost Lekkerkerker
03f5e6d6a3 Add integration_type device to songpal (#163633) 2026-02-20 16:17:47 +01:00
Joost Lekkerkerker
c2ba5d87d5 Add integration_type hub to subaru (#163643) 2026-02-20 16:17:03 +01:00
Joost Lekkerkerker
6a9fd67e05 Add integration_type hub to somfy_mylink (#163631) 2026-02-20 16:16:35 +01:00
Joost Lekkerkerker
69db5787ec Add integration_type device to stiebel_eltron (#163641) 2026-02-20 16:15:39 +01:00
Joost Lekkerkerker
8a38bace90 Add integration_type service to streamlabswater (#163642) 2026-02-20 16:15:05 +01:00
epenet
d6f3079518 Use shorthand attributes in london_air (#163601) 2026-02-20 11:49:48 +01:00
epenet
f80e1dd25b Use shorthand attributes in homematic (#163610) 2026-02-20 11:49:04 +01:00
epenet
4937c6521b Add type hint for icon property (#163609) 2026-02-20 11:43:44 +01:00
epenet
cff5a12d5f Use shorthand attributes in reddit (#163600) 2026-02-20 11:43:23 +01:00
epenet
63e4eaf79e Use shorthand attributes in netdata (#163605) 2026-02-20 11:41:56 +01:00
epenet
eccaac4e94 Use shorthand attributes in rmvtransport (#163599) 2026-02-20 11:38:11 +01:00
epenet
5d818cd2ba Use shorthand attributes in transport_nsw (#163598) 2026-02-20 11:37:40 +01:00
epenet
12591a95c6 Use shorthand attributes in torque (#163597) 2026-02-20 11:33:18 +01:00
epenet
1110ca5dc6 Use shorthand attributes in geonetnz_volcano (#163596) 2026-02-20 11:32:45 +01:00
Brett Adams
2a6f6ef684 Add reconfiguration flow to Splunk integration (#163577)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-20 09:13:32 +01:00
Manu
c173505f76 Add state_class to PlayStation Network sensors (#163591) 2026-02-20 09:10:58 +01:00
Manu
201b31c18a Add state_class to Xbox sensors (#163590) 2026-02-20 08:31:26 +01:00
Manu
cb63c1d435 Impprove oauth2 exception handling in Xbox (#163588) 2026-02-20 08:31:10 +01:00
Brett Adams
6abff84f23 Add exception translations for Splunk setup errors (#163579) 2026-02-20 00:19:03 +01:00
Patrick Vorgers
0996ad4d1d Add pagination support for IDrive e2 (#162960) 2026-02-19 22:42:04 +01:00
Franck Nijhof
3e6bc29a6a 2026.2.2 (#162950) 2026-02-13 21:05:06 +01:00
Franck Nijhof
ec8067a5a8 Bump version to 2026.2.2 2026-02-13 19:25:16 +00:00
Josef Zweck
6f47716d0a Log remaining token duration in onedrive (#162933) 2026-02-13 19:24:25 +00:00
puddly
efba5c6bcc Bump ZHA to 0.0.90 (#162894) 2026-02-13 19:24:24 +00:00
Sammy [Andrei Marinache]
d10e78079f Add Miele TQ1000WP tumble dryer programs and program phases (#162871)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Åke Strandberg <ake@strandberg.eu>
2026-02-13 19:24:23 +00:00
Jon Seager
6d4581580f Bump pytouchlinesl to 0.6.0 (#162856) 2026-02-13 19:24:21 +00:00
Yoshi Walsh
0d9a41a540 Bump pydaikin to 2.17.2 (#162846) 2026-02-13 19:24:20 +00:00
Vicx
cd69e6db73 Bump slixmpp to 1.13.2 (#162837) 2026-02-13 19:24:19 +00:00
Xitee
1320367d0d Filter out transient zero values from qBittorrent alltime stats (#162821)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:24:18 +00:00
Joost Lekkerkerker
dfa4698887 Bump pySmartThings to 3.5.2 (#162809)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-13 19:24:17 +00:00
Robert Resch
b426115de7 Bump cryptography to 46.0.5 (#162783) 2026-02-13 19:24:15 +00:00
hanwg
fb79fa37f8 Fix bug in edit_message_media action for Telegram bot (#162762) 2026-02-13 19:24:14 +00:00
Simone Chemelli
6a5f7bf424 Fix image platform state for Vodafone Station (#162747)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-13 19:24:13 +00:00
Simone Chemelli
142ca6dec1 Fix alarm refresh warning for Comelit SimpleHome (#162710) 2026-02-13 19:24:12 +00:00
epenet
0f986c24d0 Fix unavailable status in Tuya (#162709) 2026-02-13 19:24:11 +00:00
Josef Zweck
01f2b7b6f6 Bump onedrive-personal-sdk to 0.1.2 (#162689) 2026-02-13 19:24:09 +00:00
Michael
b9469027f5 Fix handling when FRITZ!Box reboots in FRITZ!Box Tools (#162679) 2026-02-13 19:24:08 +00:00
Tomás Correia
fbb94af748 fix to cloudflare r2 setup screen info (#162677) 2026-02-13 19:24:07 +00:00
Michael
148bdf6e3a Fix handling when FRITZ!Box reboots in FRITZ!Smarthome (#162676) 2026-02-13 19:24:05 +00:00
starkillerOG
91999f8871 Bump reolink-aio to 0.19.0 (#162672) 2026-02-13 19:24:04 +00:00
Jeef
aecca4eb99 Bump intellifire4py to 4.3.1 (#162659) 2026-02-13 19:24:03 +00:00
Allen Porter
bf8aa49bae Improve MCP SSE fallback error handling (#162655) 2026-02-13 19:24:02 +00:00
Joost Lekkerkerker
4423425683 Pin setuptools to 81.0.0 (#162589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:24:01 +00:00
Aaron Godfrey
44202da53d Increase max tasks retrieved per page to prevent timeout (#162587) 2026-02-13 19:23:59 +00:00
Thomas55555
9f7dfb72c4 Bump aioautomower to 2.7.3 (#162583)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:58 +00:00
Michael
de07a69e4f Bump aioimmich to 0.12.0 (#162573)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:57 +00:00
Maikel Punie
bbf4c38115 migrate velbus config entries (#162565) 2026-02-13 19:23:56 +00:00
ElCruncharino
e1bb5d52ef Add timeout to B2 metadata downloads to prevent backup hang (#162562) 2026-02-13 19:23:54 +00:00
hanwg
eb64b6bdee Fix config flow bug for Telegram bot (#162555) 2026-02-13 19:23:53 +00:00
Andrea Turri
ecb288b735 Add new Miele mappings (#162544) 2026-02-13 19:23:52 +00:00
Norbert Rittel
a419c9c420 Sentence-case "speech-to-text" in google_cloud (#162534) 2026-02-13 19:23:51 +00:00
Brett Adams
dd29133324 Fix Tesla Fleet partner registration to use all regions (#162525)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:23:50 +00:00
Allen Porter
90f22ea516 Bump grpc to 1.78.0 (#162520) 2026-02-13 19:23:48 +00:00
Peter Grauvogel
9db1428265 Fix Green Planet Energy price unit conversion (#162511) 2026-02-13 19:23:47 +00:00
Denis Shulyaka
a696b05b0d Fix JSON serialization of time objects in Cloud conversation tool results (#162506) 2026-02-13 19:23:46 +00:00
Denis Shulyaka
77ddb63b73 Fix JSON serialization of time objects in Open Router tool results (#162505) 2026-02-13 19:23:44 +00:00
Denis Shulyaka
4180a6e176 Fix JSON serialization of time objects in Ollama tool results (#162502)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:23:43 +00:00
Denis Shulyaka
6d74c912d2 Fix JSON serialization of datetime objects in Google Generative AI tool results (#162495) 2026-02-13 19:23:42 +00:00
Denis Shulyaka
8a01dfcc00 Fix JSON serialization of time objects in OpenAI tool results (#162490) 2026-02-13 19:23:40 +00:00
Brett Adams
9722898dc6 Fix device_class of backup reserve sensor in Tessie (#162459) 2026-02-13 19:23:39 +00:00
Brett Adams
7438c71fcb Fix device_class of backup reserve sensor in teslemetry (#162458) 2026-02-13 19:23:38 +00:00
Christian Lackas
0b5e55b923 Fix absolute humidity sensor on HmIP-WGT glass thermostats (#162455) 2026-02-13 19:23:37 +00:00
ElCruncharino
61ed959e8e Fix AsyncIteratorReader blocking after stream exhaustion (#161731) 2026-02-13 19:17:20 +00:00
Jaap Pieroen
3989532465 Bump essent-dynamic-pricing to 0.3.1 (#160958)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-13 19:17:18 +00:00
Franck Nijhof
28027ddca4 2026.2.1 (#162450) 2026-02-06 22:44:07 +01:00
Franck Nijhof
fe0d7b3cca Bump version to 2026.2.1 2026-02-06 20:49:26 +00:00
jameson_uk
0dcc4e9527 dep: bump aioamazondevices to 11.1.3 (#162437) 2026-02-06 20:47:38 +00:00
Artur Pragacz
b13b189703 Make bad entity ID detection more lenient (#162425) 2026-02-06 20:47:37 +00:00
epenet
150829f599 Fix invalid yardian snaphots (#162422) 2026-02-06 20:47:36 +00:00
Joost Lekkerkerker
57dd9d9c23 Remove double unit of measurement for yardian (#162412) 2026-02-06 20:47:34 +00:00
Sab44
e2056cb12c Bump librehardwaremonitor-api to version 1.9.1 (#162409) 2026-02-06 20:47:33 +00:00
Joost Lekkerkerker
fa2c8992cf Remove entity id overwrite for ambient station (#162403) 2026-02-06 20:47:32 +00:00
Matt Zimmerman
ddf5c7fe3a Add missing config flow strings to SmartTub (#162375)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:31 +00:00
Matt Zimmerman
7034ed6d3f Bump python-smarttub to 0.0.47 (#162367)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:29 +00:00
Aaron Godfrey
9015b53c1b Fix conversion of data for todo.* actions (#162366) 2026-02-06 20:47:28 +00:00
Jordan Harvey
1cfa6561f7 Update pynintendoparental requirement to version 2.3.2.1 (#162362) 2026-02-06 20:47:27 +00:00
Shay Levy
eead02dcca Fix Shelly Linkedgo Thermostat status update (#162339) 2026-02-06 20:47:26 +00:00
Arie Catsman
456e51a221 Bump pyenphase to 2.4.5 (#162324) 2026-02-06 20:47:25 +00:00
Luo Chen
5d984ce186 Fix unicode escaping in MCP server tool response (#162319)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-06 20:47:24 +00:00
Oliver
61f45489ac Add mapping for stopped state to denonavr media player (#162283) 2026-02-06 20:47:23 +00:00
Tomás Correia
f72c643b38 Fix multipart upload to use consistent part sizes for R2/S3 (#162278) 2026-02-06 20:47:22 +00:00
Oliver
27bc26e886 Bump denonavr to 1.3.2 (#162271) 2026-02-06 20:47:20 +00:00
Thomas55555
0e9f03cbc1 Bump google_air_quality_api to 3.0.1 (#162233) 2026-02-06 20:47:19 +00:00
David Bonnes
9480c33fb0 Bump evohome-async to 1.1.3 (#162232) 2026-02-06 20:47:18 +00:00
Jonathan
3e6b8663e8 Fix device_class of backup reserve sensor (#161178) 2026-02-06 20:47:17 +00:00
epenet
1c69a83793 Fix redundant off preset in Tuya climate (#161040) 2026-02-06 20:47:16 +00:00
461 changed files with 17511 additions and 2433 deletions

View File

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

View File

@@ -583,6 +583,7 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
@@ -612,6 +613,7 @@ homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*
homeassistant.components.youtube.*
homeassistant.components.zeroconf.*
homeassistant.components.zinvolt.*
homeassistant.components.zodiac.*
homeassistant.components.zone.*
homeassistant.components.zwave_js.*

14
CODEOWNERS generated
View File

@@ -403,8 +403,8 @@ build.json @home-assistant/supervisor
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@@ -555,8 +555,6 @@ build.json @home-assistant/supervisor
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
/tests/components/fritzbox_callmonitor/ @cdce8p
/homeassistant/components/fronius/ @farmio
/tests/components/fronius/ @farmio
/homeassistant/components/frontend/ @home-assistant/frontend
@@ -1082,6 +1080,8 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
@@ -1880,8 +1880,8 @@ build.json @home-assistant/supervisor
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/weheat/ @jesperraemaekers
/tests/components/weheat/ @jesperraemaekers
/homeassistant/components/weheat/ @barryvdh
/tests/components/weheat/ @barryvdh
/homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis @mkmer
@@ -1959,6 +1959,8 @@ build.json @home-assistant/supervisor
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zinvolt/ @joostlek
/tests/components/zinvolt/ @joostlek
/homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
import json
import logging
from pathlib import Path
@@ -40,17 +39,6 @@ class RestoreBackupFileContent:
restore_homeassistant: bool
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password.
Matches the implementation in supervisor.backups.utils.password_to_key.
"""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
@@ -96,15 +84,14 @@ def _extract_backup(
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarFile(
securetar.SecureTarArchive(
restore_content.backup_file_path,
gzip=False,
mode="r",
) as ostf,
):
ostf.extractall(
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
@@ -126,10 +113,7 @@ def _extract_backup(
f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
),
gzip=backup_meta["compressed"],
key=password_to_key(restore_content.password)
if restore_content.password is not None
else None,
mode="r",
password=restore_content.password,
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),

View File

@@ -23,6 +23,7 @@ from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
]

View File

@@ -0,0 +1,73 @@
"""AirOS button component for Home Assistant."""
from __future__ import annotations
import logging
from airos.exceptions import AirOSException
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
REBOOT_BUTTON = ButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_registry_enabled_default=False,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS button from a config entry."""
async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)])
class AirOSRebootButton(AirOSEntity, ButtonEntity):
"""Button to reboot device."""
entity_description: ButtonEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: ButtonEntityDescription,
) -> None:
"""Initialize the AirOS client button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press to reboot the device."""
try:
await self.coordinator.airos_device.login()
result = await self.coordinator.airos_device.reboot()
except AirOSException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
if not result:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reboot_failed",
) from None

View File

@@ -2,16 +2,20 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
from typing import Any
from airos.discovery import airos_discover_devices
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
)
import voluptuous as vol
@@ -36,15 +40,27 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .const import (
DEFAULT_SSL,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
DEVICE_NAME,
DOMAIN,
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
# Discovery duration in seconds, airOS announces every 20 seconds
DISCOVER_INTERVAL: int = 30
STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
@@ -58,6 +74,10 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend(
{vol.Required(CONF_HOST): str}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
@@ -65,14 +85,29 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
MINOR_VERSION = 1
_discovery_task: asyncio.Task | None = None
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
self.discovered_devices: dict[str, dict[str, Any]] = {}
self.discovery_abort_reason: str | None = None
self.selected_device_info: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self.errors = {}
return self.async_show_menu(
step_id="user", menu_options=["discovery", "manual"]
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the manual input of host and credentials."""
self.errors = {}
@@ -84,7 +119,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=self.errors
)
async def _validate_and_get_device_info(
@@ -220,3 +255,163 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=self.errors,
)
async def async_step_discovery(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Start the discovery process."""
if self._discovery_task and self._discovery_task.done():
self._discovery_task = None
# Handle appropriate 'errors' as abort through progress_done
if self.discovery_abort_reason:
return self.async_show_progress_done(
next_step_id=self.discovery_abort_reason
)
# Abort through progress_done if no devices were found
if not self.discovered_devices:
_LOGGER.debug(
"No (new or unconfigured) airOS devices found during discovery"
)
return self.async_show_progress_done(
next_step_id="discovery_no_devices"
)
# Skip selecting a device if only one new/unconfigured device was found
if len(self.discovered_devices) == 1:
self.selected_device_info = list(self.discovered_devices.values())[0]
return self.async_show_progress_done(next_step_id="configure_device")
return self.async_show_progress_done(next_step_id="select_device")
if not self._discovery_task:
self.discovered_devices = {}
self._discovery_task = self.hass.async_create_task(
self._async_run_discovery_with_progress()
)
# Show the progress bar and wait for discovery to complete
return self.async_show_progress(
step_id="discovery",
progress_action="discovering",
progress_task=self._discovery_task,
description_placeholders={"seconds": str(DISCOVER_INTERVAL)},
)
async def async_step_select_device(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Select a discovered device."""
if discovery_info is not None:
selected_mac = discovery_info[MAC_ADDRESS]
self.selected_device_info = self.discovered_devices[selected_mac]
return await self.async_step_configure_device()
list_options = {
mac: f"{device.get(HOSTNAME, mac)} ({device.get(IP_ADDRESS, DEVICE_NAME)})"
for mac, device in self.discovered_devices.items()
}
return self.async_show_form(
step_id="select_device",
data_schema=vol.Schema({vol.Required(MAC_ADDRESS): vol.In(list_options)}),
)
async def async_step_configure_device(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Configure the selected device."""
self.errors = {}
if user_input is not None:
config_data = {
**user_input,
CONF_HOST: self.selected_device_info[IP_ADDRESS],
}
validated_info = await self._validate_and_get_device_info(config_data)
if validated_info:
return self.async_create_entry(
title=validated_info["title"],
data=validated_info["data"],
)
device_name = self.selected_device_info.get(
HOSTNAME, self.selected_device_info.get(IP_ADDRESS, DEVICE_NAME)
)
return self.async_show_form(
step_id="configure_device",
data_schema=STEP_DISCOVERY_DATA_SCHEMA,
errors=self.errors,
description_placeholders={"device_name": device_name},
)
async def _async_run_discovery_with_progress(self) -> None:
"""Run discovery with an embedded progress update loop."""
progress_bar = self.hass.async_create_task(self._async_update_progress_bar())
known_mac_addresses = {
entry.unique_id.lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id
}
try:
devices = await airos_discover_devices(DISCOVER_INTERVAL)
except AirOSEndpointError:
self.discovery_abort_reason = "discovery_detect_error"
except AirOSListenerError:
self.discovery_abort_reason = "discovery_listen_error"
except Exception:
self.discovery_abort_reason = "discovery_failed"
_LOGGER.exception("An error occurred during discovery")
else:
self.discovered_devices = {
mac_addr: info
for mac_addr, info in devices.items()
if mac_addr.lower() not in known_mac_addresses
}
_LOGGER.debug(
"Discovery task finished. Found %s new devices",
len(self.discovered_devices),
)
finally:
progress_bar.cancel()
async def _async_update_progress_bar(self) -> None:
"""Update progress bar every second."""
try:
for i in range(DISCOVER_INTERVAL):
progress = (i + 1) / DISCOVER_INTERVAL
self.async_update_progress(progress)
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
async def async_step_discovery_no_devices(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery finds no (unconfigured) devices."""
return self.async_abort(reason="no_devices_found")
async def async_step_discovery_listen_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery is unable to listen on the port."""
return self.async_abort(reason="listen_error")
async def async_step_discovery_detect_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery receives incorrect broadcasts."""
return self.async_abort(reason="detect_error")
async def async_step_discovery_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery fails for other reasons."""
return self.async_abort(reason="discovery_failed")

View File

@@ -12,3 +12,10 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
HOSTNAME = "hostname"
IP_ADDRESS = "ip_address"
MAC_ADDRESS = "mac_address"
DEVICE_NAME = "airOS device"

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.3"]
"requirements": ["airos==0.6.4"]
}

View File

@@ -2,6 +2,10 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"detect_error": "Unable to process discovered devices data, check the documentation for supported devices",
"discovery_failed": "Unable to start discovery, check logs for details",
"listen_error": "Unable to start listening for devices",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
@@ -13,37 +17,36 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Ubiquiti airOS device",
"progress": {
"connecting": "Connecting to the airOS device",
"discovering": "Listening for any airOS devices for {seconds} seconds"
},
"step": {
"reauth_confirm": {
"configure_device": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
"password": "[%key:component::airos::config::step::manual::data_description::password%]",
"username": "[%key:component::airos::config::step::manual::data_description::username%]"
},
"description": "Enter the username and password for {device_name}",
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
"user": {
"manual": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
@@ -67,6 +70,49 @@
"name": "Advanced settings"
}
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
"select_device": {
"data": {
"mac_address": "Select the device to configure"
},
"data_description": {
"mac_address": "Select the device MAC address"
}
},
"user": {
"menu_options": {
"discovery": "Listen for airOS devices on the network",
"manual": "Manually configure airOS device"
}
}
}
},
@@ -157,6 +203,9 @@
},
"key_data_missing": {
"message": "Key data not returned from device"
},
"reboot_failed": {
"message": "The device did not accept the reboot request. Try again, or check your device web interface for errors."
}
}
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import aiohttp
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
@@ -31,11 +33,27 @@ async def async_setup_entry(
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
client = AladdinConnectClient(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
doors = await client.get_doors()
try:
doors = await client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)

View File

@@ -11,6 +11,18 @@ API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigFlowAuth(Auth):
"""Provide Aladdin Connect Genie authentication for config flow validation."""
def __init__(self, websession: ClientSession, access_token: str) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(websession, API_URL, access_token, API_KEY)
async def async_get_access_token(self) -> str:
"""Return the access token."""
return self.access_token
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""

View File

@@ -4,12 +4,14 @@ from collections.abc import Mapping
import logging
from typing import Any
from genie_partner_sdk.client import AladdinConnectClient
import jwt
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from .api import AsyncConfigFlowAuth
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -52,11 +54,25 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
# Extract the user ID from the JWT token's 'sub' field
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
try:
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
user_id = token["sub"]
except jwt.DecodeError, KeyError:
return self.async_abort(reason="oauth_error")
client = AladdinConnectClient(
AsyncConfigFlowAuth(
aiohttp_client.async_get_clientsession(self.hass),
data["token"]["access_token"],
)
)
user_id = token["sub"]
try:
await client.get_doors()
except Exception: # noqa: BLE001
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:

View File

@@ -7,39 +7,31 @@ rules:
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: todo
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any service actions.
docs-high-level-description: done
docs-installation-instructions:
status: todo
comment: Documentation needs to be created.
docs-removal-instructions:
status: todo
comment: Documentation needs to be created.
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure:
status: todo
comment: Config flow does not currently test connection during setup.
test-before-setup: todo
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: todo
comment: Documentation needs to be created.
docs-installation-parameters:
status: todo
comment: Documentation needs to be created.
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
@@ -52,29 +44,17 @@ rules:
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update:
status: todo
comment: Documentation needs to be created.
docs-examples:
status: todo
comment: Documentation needs to be created.
docs-known-limitations:
status: todo
comment: Documentation needs to be created.
docs-supported-devices:
status: todo
comment: Documentation needs to be created.
docs-supported-functions:
status: todo
comment: Documentation needs to be created.
docs-troubleshooting:
status: todo
comment: Documentation needs to be created.
docs-use-cases:
status: todo
comment: Documentation needs to be created.
discovery: done
discovery-update-info:
status: exempt
comment: Integration connects via the cloud and not locally.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
@@ -86,7 +66,7 @@ rules:
repair-issues: todo
stale-devices:
status: todo
comment: Stale devices can be done dynamically
comment: We can automatically remove removed devices
# Platinum
async-dependency: todo

View File

@@ -4,6 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",

View File

@@ -75,7 +75,7 @@
"name": "Go to preset"
},
"ptz_control": {
"description": "Moves (pan/tilt) and/or zoom a PTZ camera.",
"description": "Moves (pan/tilt) and/or zooms a PTZ camera.",
"fields": {
"entity_id": {
"description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]",

View File

@@ -38,7 +38,6 @@ def get_app_entity_description(
translation_key="apps",
name=name_slug,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.apps.get(name_slug),
)
@@ -52,7 +51,6 @@ def get_core_integration_entity_description(
translation_key="core_integrations",
name=name,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.core_integrations.get(domain),
)
@@ -66,7 +64,6 @@ def get_custom_integration_entity_description(
translation_key="custom_integrations",
translation_placeholders={"custom_integration_domain": domain},
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.custom_integrations.get(domain),
)
@@ -77,7 +74,6 @@ GENERAL_SENSORS = [
translation_key="total_active_installations",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.active_installations,
),
AnalyticsSensorEntityDescription(
@@ -85,7 +81,6 @@ GENERAL_SENSORS = [
translation_key="total_reports_integrations",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.reports_integrations,
),
]

View File

@@ -24,14 +24,23 @@
},
"entity": {
"sensor": {
"apps": {
"unit_of_measurement": "active installations"
},
"core_integrations": {
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"custom_integrations": {
"name": "{custom_integration_domain} (custom)"
"name": "{custom_integration_domain} (custom)",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"total_active_installations": {
"name": "Total active installations"
"name": "Total active installations",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"total_reports_integrations": {
"name": "Total reported integrations"
"name": "Total reported integrations",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
}
}
},

View File

@@ -112,19 +112,12 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in (
"claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
)
if model_info.id != "claude-3-haiku-20240307"
and model_info.id[-2:-1] != "-"
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,

View File

@@ -37,8 +37,6 @@ DEFAULT = {
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku
"claude-3-opus",
"claude-3-haiku",
]
@@ -51,7 +49,7 @@ NON_ADAPTIVE_THINKING_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3",
"claude-3-haiku",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
@@ -60,19 +58,13 @@ UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3",
"claude-3-haiku",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]
DEPRECATED_MODELS = [
"claude-3-5-haiku",
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-opus",
"claude-3",
]

View File

@@ -132,11 +132,21 @@ class ContentDetails:
"""Native data for AssistantContent."""
citation_details: list[CitationDetails] = field(default_factory=list)
thinking_signature: str | None = None
redacted_thinking: str | None = None
def has_content(self) -> bool:
"""Check if there is any content."""
"""Check if there is any text content."""
return any(detail.length > 0 for detail in self.citation_details)
def __bool__(self) -> bool:
"""Check if there is any thinking content or citations."""
return (
self.thinking_signature is not None
or self.redacted_thinking is not None
or self.has_citations()
)
def has_citations(self) -> bool:
"""Check if there are any citations."""
return any(detail.citations for detail in self.citation_details)
@@ -246,29 +256,28 @@ def _convert_content(
content=[],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
]
if isinstance(content.native, ThinkingBlock):
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.signature,
if isinstance(content.native, ContentDetails):
if content.native.thinking_signature:
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.thinking_signature,
)
)
)
elif isinstance(content.native, RedactedThinkingBlock):
redacted_thinking_block = RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.data,
)
if isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
redacted_thinking_block,
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
redacted_thinking_block
if content.native.redacted_thinking:
messages[-1]["content"].append( # type: ignore[union-attr]
RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.redacted_thinking,
)
)
if content.content:
current_index = 0
for detail in (
@@ -309,6 +318,7 @@ def _convert_content(
text=content.content[current_index:],
)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
@@ -328,6 +338,14 @@ def _convert_content(
for tool_call in content.tool_calls
]
)
if (
isinstance(messages[-1]["content"], list)
and len(messages[-1]["content"]) == 1
and messages[-1]["content"][0]["type"] == "text"
):
# If there is only one text block, simplify the content to a string
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
@@ -379,8 +397,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None
has_native = False
first_block: bool
first_block: bool = True
async for response in stream:
LOGGER.debug("Received response: %s", response)
@@ -401,13 +418,12 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details.has_citations():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
@@ -418,12 +434,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
and content_details.has_content()
)
):
if content_details.has_citations():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"}
has_native = False
first_block = False
content_details.add_citation_detail()
if response.content_block.text:
@@ -432,14 +447,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
)
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if first_block or has_native:
if content_details.has_citations():
if first_block or content_details.thinking_signature:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug(
@@ -447,17 +461,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if has_native:
if content_details.has_citations():
if first_block or content_details.redacted_thinking:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
yield {"native": response.content_block}
has_native = True
content_details.redacted_thinking = response.content_block.data
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
@@ -467,7 +479,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):
if content_details.has_citations():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
@@ -510,19 +522,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
else:
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
yield {
"native": ThinkingBlock(
type="thinking",
thinking="",
signature=response.delta.signature,
if response.delta.text:
content_details.citation_details[-1].length += len(
response.delta.text
)
}
has_native = True
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
if response.delta.thinking:
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
content_details.thinking_signature = response.delta.signature
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
@@ -549,7 +558,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if content_details.has_citations():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.78.0"]
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -10,15 +10,7 @@ rules:
Integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
* Remove integration setup from the config flow init test
* Make `mock_setup_entry` a separate fixture
* Use the mock_config_entry fixture in `test_duplicate_entry`
* `test_duplicate_entry`: Patch `homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list`
* Fix docstring and name for `test_form_invalid_auth` (does not only test auth)
* In `test_form_invalid_auth`, make sure the test run until CREATE_ENTRY to test that the flow is able to recover
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
import voluptuous as vol
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
)
from .config_flow import get_model_list
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -67,13 +67,23 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
suggested_model = "claude-opus-4-5"
elif "haiku" in model:
suggested_model = "claude-haiku-4-5"
family = "claude-opus"
elif "sonnet" in model:
suggested_model = "claude-sonnet-4-5"
family = "claude-sonnet"
else:
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
),
vol.UNDEFINED,
)
schema = vol.Schema(
{

View File

@@ -64,6 +64,6 @@ class AtagSensor(AtagEntity, SensorEntity):
return self.coordinator.atag.report[self._id].state
@property
def icon(self):
def icon(self) -> str:
"""Return icon."""
return self.coordinator.atag.report[self._id].icon

View File

@@ -33,3 +33,5 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db",
"home-assistant_v2.db-wal",
]
SECURETAR_CREATE_VERSION = 2

View File

@@ -20,13 +20,9 @@ import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
from securetar import SecureTarArchive, atomic_contents_add
from homeassistant.backup_restore import (
RESTORE_BACKUP_FILE,
RESTORE_BACKUP_RESULT_FILE,
password_to_key,
)
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, RESTORE_BACKUP_RESULT_FILE
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
@@ -60,6 +56,7 @@ from .const import (
EXCLUDE_DATABASE_FROM_BACKUP,
EXCLUDE_FROM_BACKUP,
LOGGER,
SECURETAR_CREATE_VERSION,
)
from .models import (
AddonInfo,
@@ -1858,20 +1855,22 @@ class CoreBackupReaderWriter(BackupReaderWriter):
return False
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
)
with outer_secure_tarfile as outer_secure_tarfile_tarfile:
with SecureTarArchive(
tar_file_path,
"w",
bufsize=BUF_SIZE,
create_version=SECURETAR_CREATE_VERSION,
password=password,
) as outer_secure_tarfile:
raw_bytes = json_bytes(backup_data)
fileobj = io.BytesIO(raw_bytes)
tar_info = tarfile.TarInfo(name="./backup.json")
tar_info.size = len(raw_bytes)
tar_info.mtime = int(time.time())
outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
with outer_secure_tarfile.create_inner_tar(
outer_secure_tarfile.tar.addfile(tar_info, fileobj=fileobj)
with outer_secure_tarfile.create_tar(
"./homeassistant.tar.gz",
gzip=True,
key=password_to_key(password) if password is not None else None,
) as core_tar:
atomic_contents_add(
tar_file=core_tar,

View File

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

View File

@@ -8,7 +8,6 @@ import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
@@ -16,9 +15,14 @@ import threading
from typing import IO, Any, cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from securetar import (
SecureTarArchive,
SecureTarError,
SecureTarFile,
SecureTarReadError,
SecureTarRootKeyContext,
)
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
@@ -29,7 +33,7 @@ from homeassistant.util.async_iterator import (
)
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
from .models import AddonInfo, AgentBackup, Folder
@@ -132,17 +136,23 @@ def suggested_filename(backup: AgentBackup) -> str:
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
"""Validate the password.
This assumes every inner tar is encrypted with the same secure tar version and
same password.
"""
with SecureTarArchive(
path, "r", bufsize=BUF_SIZE, password=password
) as backup_file:
compressed = False
ha_tar_name = "homeassistant.tar"
try:
ha_tar = backup_file.extractfile(ha_tar_name)
ha_tar = backup_file.tar.extractfile(ha_tar_name)
except KeyError:
compressed = True
ha_tar_name = "homeassistant.tar.gz"
try:
ha_tar = backup_file.extractfile(ha_tar_name)
ha_tar = backup_file.tar.extractfile(ha_tar_name)
except KeyError:
LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found")
return False
@@ -150,13 +160,12 @@ def validate_password(path: Path, password: str | None) -> bool:
with SecureTarFile(
path, # Not used
gzip=compressed,
key=password_to_key(password) if password is not None else None,
mode="r",
password=password,
fileobj=ha_tar,
):
# If we can read the tar file, the password is correct
return True
except tarfile.ReadError:
except tarfile.ReadError, SecureTarReadError:
LOGGER.debug("Invalid password")
return False
except Exception: # noqa: BLE001
@@ -168,22 +177,23 @@ def validate_password_stream(
input_stream: IO[bytes],
password: str | None,
) -> None:
"""Decrypt a backup."""
with (
tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar,
):
for obj in input_tar:
"""Validate the password.
This assumes every inner tar is encrypted with the same secure tar version and
same password.
"""
with SecureTarArchive(
fileobj=input_stream,
mode="r",
bufsize=BUF_SIZE,
streaming=True,
password=password,
) as input_archive:
for obj in input_archive.tar:
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if istf.securetar_header.plaintext_size is None:
with input_archive.extract_tar(obj) as decrypted:
if decrypted.plaintext_size is None:
raise UnsupportedSecureTarVersion
try:
decrypted.read(1) # Read a single byte to trigger the decryption
@@ -212,21 +222,25 @@ def decrypt_backup(
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: NonceGenerator,
key_context: SecureTarRootKeyContext,
) -> None:
"""Decrypt a backup."""
error: Exception | None = None
try:
try:
with (
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
SecureTarArchive(
fileobj=input_stream,
mode="r",
bufsize=BUF_SIZE,
streaming=True,
password=password,
) as input_archive,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
):
_decrypt_backup(backup, input_tar, output_tar, password)
_decrypt_backup(backup, input_archive, output_tar)
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
error = err
@@ -248,19 +262,18 @@ def decrypt_backup(
def _decrypt_backup(
backup: AgentBackup,
input_tar: tarfile.TarFile,
input_archive: SecureTarArchive,
output_tar: tarfile.TarFile,
password: str | None,
) -> None:
"""Decrypt a backup."""
expected_archives = _get_expected_archives(backup)
for obj in input_tar:
for obj in input_archive.tar:
# We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json"
object_path = PurePath(obj.name)
if object_path == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is decrypted
if not (reader := input_tar.extractfile(obj)):
if not (reader := input_archive.tar.extractfile(obj)):
raise DecryptError
metadata = json_loads_object(reader.read())
metadata["protected"] = False
@@ -272,21 +285,15 @@ def _decrypt_backup(
prefix, _, suffix = object_path.name.partition(".")
if suffix not in ("tar", "tgz", "tar.gz"):
LOGGER.debug("Unknown file %s will not be decrypted", obj.name)
output_tar.addfile(obj, input_tar.extractfile(obj))
output_tar.addfile(obj, input_archive.tar.extractfile(obj))
continue
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name)
output_tar.addfile(obj, input_tar.extractfile(obj))
output_tar.addfile(obj, input_archive.tar.extractfile(obj))
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if (plaintext_size := istf.securetar_header.plaintext_size) is None:
with input_archive.extract_tar(obj) as decrypted:
# Guard against SecureTar v1 which doesn't store plaintext size
if (plaintext_size := decrypted.plaintext_size) is None:
raise UnsupportedSecureTarVersion
decrypted_obj = copy.deepcopy(obj)
decrypted_obj.size = plaintext_size
@@ -300,7 +307,7 @@ def encrypt_backup(
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
nonces: NonceGenerator,
key_context: SecureTarRootKeyContext,
) -> None:
"""Encrypt a backup."""
error: Exception | None = None
@@ -310,11 +317,16 @@ def encrypt_backup(
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
SecureTarArchive(
fileobj=output_stream,
mode="w",
bufsize=BUF_SIZE,
streaming=True,
root_key_context=key_context,
create_version=SECURETAR_CREATE_VERSION,
) as output_archive,
):
_encrypt_backup(backup, input_tar, output_tar, password, nonces)
_encrypt_backup(backup, input_tar, output_archive)
except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err)
error = err
@@ -337,9 +349,7 @@ def encrypt_backup(
def _encrypt_backup(
backup: AgentBackup,
input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile,
password: str | None,
nonces: NonceGenerator,
output_archive: SecureTarArchive,
) -> None:
"""Encrypt a backup."""
inner_tar_idx = 0
@@ -357,29 +367,20 @@ def _encrypt_backup(
updated_metadata_b = json.dumps(metadata).encode()
metadata_obj = copy.deepcopy(obj)
metadata_obj.size = len(updated_metadata_b)
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
output_archive.tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue
prefix, _, suffix = object_path.name.partition(".")
if suffix not in ("tar", "tgz", "tar.gz"):
LOGGER.debug("Unknown file %s will not be encrypted", obj.name)
output_tar.addfile(obj, input_tar.extractfile(obj))
output_archive.tar.addfile(obj, input_tar.extractfile(obj))
continue
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
nonce=nonces.get(inner_tar_idx),
output_archive.import_tar(
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
)
inner_tar_idx += 1
with istf.encrypt(obj) as encrypted:
encrypted_obj = copy.deepcopy(obj)
encrypted_obj.size = encrypted.encrypted_size
output_tar.addfile(encrypted_obj, encrypted)
@dataclass(kw_only=True)
@@ -391,21 +392,6 @@ class _CipherWorkerStatus:
writer: AsyncIteratorWriter
class NonceGenerator:
"""Generate nonces for encryption."""
def __init__(self) -> None:
"""Initialize the generator."""
self._nonces: dict[int, bytes] = {}
def get(self, index: int) -> bytes:
"""Get a nonce for the given index."""
if index not in self._nonces:
# Generate a new nonce for the given index
self._nonces[index] = os.urandom(16)
return self._nonces[index]
class _CipherBackupStreamer:
"""Encrypt or decrypt a backup."""
@@ -417,7 +403,7 @@ class _CipherBackupStreamer:
str | None,
Callable[[Exception | None], None],
int,
NonceGenerator,
SecureTarRootKeyContext,
],
None,
]
@@ -435,7 +421,7 @@ class _CipherBackupStreamer:
self._hass = hass
self._open_stream = open_stream
self._password = password
self._nonces = NonceGenerator()
self._key_context = SecureTarRootKeyContext(password)
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
@@ -466,7 +452,7 @@ class _CipherBackupStreamer:
self._password,
on_done,
self.size(),
self._nonces,
self._key_context,
],
)
worker_status = _CipherWorkerStatus(

View File

@@ -74,7 +74,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return self._feature.is_on
@property
def brightness(self):
def brightness(self) -> int | None:
"""Return the name."""
return self._feature.brightness

View File

@@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
@@ -113,7 +112,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return target_temp.value
@property
def _hvac_mode_value(self) -> int | str | None:
def _hvac_mode_value(self) -> int | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
return None
@@ -124,16 +123,14 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
"""Return hvac operation ie. heat, cool mode."""
if (hvac_mode_value := self._hvac_mode_value) is None:
return None
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
return try_parse_enum(HVACMode, hvac_mode_value)
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
action = self.coordinator.data.state.hvac_action
if not action or not isinstance(action.value, int):
if (
action := self.coordinator.data.state.hvac_action
) is None or action.value is None:
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())

View File

@@ -17,24 +17,24 @@ async def async_get_config_entry_diagnostics(
# Build diagnostic data from both coordinators
diagnostics = {
"info": data.info.to_dict(),
"device": data.device.to_dict(),
"info": data.info.model_dump(),
"device": data.device.model_dump(),
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.to_dict(),
"sensor": data.fast_coordinator.data.sensor.to_dict(),
"dhw": data.fast_coordinator.data.dhw.to_dict(),
"state": data.fast_coordinator.data.state.model_dump(),
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump(),
},
"static": data.static.to_dict(),
"static": data.static.model_dump(),
}
# Add DHW config and schedule from slow coordinator if available
if data.slow_coordinator.data:
slow_data = {}
if data.slow_coordinator.data.dhw_config:
slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.to_dict()
slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.model_dump()
if data.slow_coordinator.data.dhw_schedule:
slow_data["dhw_schedule"] = (
data.slow_coordinator.data.dhw_schedule.to_dict()
data.slow_coordinator.data.dhw_schedule.model_dump()
)
if slow_data:
diagnostics["slow_coordinator_data"] = slow_data

View File

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

View File

@@ -110,12 +110,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if (operating_mode := self.coordinator.data.dhw.operating_mode) is None:
if (
operating_mode := self.coordinator.data.dhw.operating_mode
) is None or operating_mode.value is None:
return None
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
if isinstance(operating_mode.value, int):
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
return None
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
@property
def current_temperature(self) -> float | None:

View File

@@ -31,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
def _convert_image_for_editing(data: bytes) -> tuple[bytes, str]:
"""Ensure the image data is in a format accepted by OpenAI image edits."""
img: Image.Image
stream = io.BytesIO(data)
with Image.open(stream) as img:
mode = img.mode

View File

@@ -199,7 +199,7 @@ class Control4Light(Control4Entity, LightEntity):
return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0
@property
def brightness(self):
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
if self._is_dimmer:
for var in CONTROL4_DIMMER_VARS:

View File

@@ -132,7 +132,7 @@ class DecoraWifiLight(LightEntity):
return self._switch.serial
@property
def brightness(self):
def brightness(self) -> int:
"""Return the brightness of the dimmer switch."""
return int(self._switch.brightness * 255 / 100)

View File

@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
"requirements": ["pydoods==1.0.2", "Pillow==12.1.1"]
}

View File

@@ -1,7 +1,7 @@
{
"domain": "dwd_weather_warnings",
"name": "Deutscher Wetterdienst (DWD) Weather Warnings",
"codeowners": ["@runningman84", "@stephan192", "@andarotajo"],
"codeowners": ["@runningman84", "@stephan192"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings",
"integration_type": "service",

View File

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

View File

@@ -8,17 +8,24 @@ from typing import TYPE_CHECKING, Any
from deebot_client.capabilities import Capabilities, DeviceType
from deebot_client.device import Device
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.models import CleanAction, CleanMode, Room, State
from deebot_client.events import (
CachedMapInfoEvent,
FanSpeedEvent,
RoomsEvent,
StateEvent,
)
from deebot_client.events.map import Map
from deebot_client.models import CleanAction, CleanMode, State
import sucks
from homeassistant.components.vacuum import (
Segment,
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
@@ -29,6 +36,7 @@ from .entity import EcovacsEntity, EcovacsLegacyEntity
from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
_SEGMENTS_SEPARATOR = "_"
ATTR_ERROR = "error"
@@ -218,7 +226,8 @@ class EcovacsVacuum(
"""Initialize the vacuum."""
super().__init__(device, device.capabilities)
self._rooms: list[Room] = []
self._room_event: RoomsEvent | None = None
self._maps: dict[str, Map] = {}
if fan_speed := self._capability.fan_speed:
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
@@ -226,14 +235,13 @@ class EcovacsVacuum(
get_name_key(level) for level in fan_speed.types
]
if self._capability.map and self._capability.clean.action.area:
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_rooms(event: RoomsEvent) -> None:
self._rooms = event.rooms
self.async_write_ha_state()
async def on_status(event: StateEvent) -> None:
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()
@@ -249,8 +257,20 @@ class EcovacsVacuum(
self._subscribe(self._capability.fan_speed.event, on_fan_speed)
if map_caps := self._capability.map:
async def on_rooms(event: RoomsEvent) -> None:
self._room_event = event
self._check_segments_changed()
self.async_write_ha_state()
self._subscribe(map_caps.rooms.event, on_rooms)
async def on_map_info(event: CachedMapInfoEvent) -> None:
self._maps = {map_obj.id: map_obj for map_obj in event.maps}
self._check_segments_changed()
self._subscribe(map_caps.cached_info.event, on_map_info)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes.
@@ -259,7 +279,10 @@ class EcovacsVacuum(
is lowercase snake_case.
"""
rooms: dict[str, Any] = {}
for room in self._rooms:
if self._room_event is None:
return rooms
for room in self._room_event.rooms:
# convert room name to snake_case to meet the convention
room_name = slugify(room.name)
room_values = rooms.get(room_name)
@@ -338,11 +361,11 @@ class EcovacsVacuum(
translation_placeholders={"name": name},
)
if command in "spot_area":
if command == "spot_area":
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.SPOT_AREA,
str(params["rooms"]),
params["rooms"],
params.get("cleanings", 1),
)
)
@@ -350,7 +373,7 @@ class EcovacsVacuum(
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.CUSTOM_AREA,
str(params["coordinates"]),
params["coordinates"],
params.get("cleanings", 1),
)
)
@@ -374,3 +397,116 @@ class EcovacsVacuum(
)
return await self._device.execute_command(position_commands[0])
@callback
def _check_segments_changed(self) -> None:
"""Check if segments have changed and create repair issue."""
last_seen = self.last_seen_segments
if last_seen is None:
return
last_seen_ids = {seg.id for seg in last_seen}
current_ids = {seg.id for seg in self._get_segments()}
if current_ids != last_seen_ids:
self.async_create_segments_issue()
def _get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
last_seen = self.last_seen_segments or []
if self._room_event is None or not self._maps:
# If we don't have the necessary information to determine segments, return the last
# seen segments to avoid temporarily losing all segments until we get the necessary
# information, which could cause unnecessary issues to be created
return last_seen
map_id = self._room_event.map_id
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
return []
id_prefix = f"{map_id}{_SEGMENTS_SEPARATOR}"
other_map_ids = {
map_obj.id
for map_obj in self._maps.values()
if map_obj.id != self._room_event.map_id
}
# Include segments from the current map and any segments from other maps that were
# previously seen, as we want to continue showing segments from other maps for
# mapping purposes
segments = [
seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids
]
segments.extend(
Segment(
id=f"{id_prefix}{room.id}",
name=room.name,
group=map_obj.name,
)
for room in self._room_event.rooms
)
return segments
async def async_get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
return self._get_segments()
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean.
Only cleans segments from the currently selected map.
"""
if not self._maps:
_LOGGER.warning("No map information available, cannot clean segments")
return
valid_room_ids: list[int | float] = []
for composite_id in segment_ids:
map_id, segment_id = _split_composite_id(composite_id)
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
continue
if not map_obj.using:
room_name = next(
(
segment.name
for segment in self.last_seen_segments or []
if segment.id == composite_id
),
"",
)
_LOGGER.warning(
'Map "%s" is not currently selected, skipping segment "%s" (%s)',
map_obj.name,
room_name,
segment_id,
)
continue
valid_room_ids.append(int(segment_id))
if not valid_room_ids:
_LOGGER.warning(
"No valid segments to clean after validation, skipping clean segments command"
)
return
if TYPE_CHECKING:
# Supported feature is only added if clean.action.area is not None
assert self._capability.clean.action.area is not None
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.SPOT_AREA,
valid_room_ids,
1,
)
)
@callback
def _split_composite_id(composite_id: str) -> tuple[str, str]:
"""Split a composite ID into its components."""
map_id, _, segment_id = composite_id.partition(_SEGMENTS_SEPARATOR)
return map_id, segment_id

View File

@@ -13,7 +13,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -59,6 +64,15 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE,
),
EgaugeSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
native_value_fn=lambda data, register: data.measurements[register],
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.CURRENT,
),
)

View File

@@ -4,7 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "device",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["enocean"],
"requirements": ["enocean==0.50"],

View File

@@ -300,16 +300,23 @@ class RuntimeEntryData:
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
info_types_to_platform = INFO_TYPE_TO_PLATFORM
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
list
)
for info in infos:
infos_by_type[type(info)].append(info)
info_type = type(info)
if platform := info_types_to_platform.get(info_type):
needed_platforms.add(platform)
infos_by_type[info_type].append(info)
else:
_LOGGER.warning(
"Entity type %s is not supported in this version of Home Assistant",
info_type,
)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
for type_, callbacks in self.entity_info_callbacks.items():
# If all entities for a type are removed, we

View File

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

View File

@@ -75,7 +75,7 @@ class EufyHomeLight(LightEntity):
self._attr_is_on = self._bulb.power
@property
def brightness(self):
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return int(self._brightness * 255 / 100)
@@ -88,7 +88,7 @@ class EufyHomeLight(LightEntity):
)
@property
def hs_color(self):
def hs_color(self) -> tuple[float, float] | None:
"""Return the color of this light."""
return self._hs

View File

@@ -72,7 +72,7 @@ class FitbitApi(ABC):
configuration = Configuration()
configuration.pool_manager = async_get_clientsession(self._hass)
configuration.access_token = token[CONF_ACCESS_TOKEN]
return ApiClient(configuration)
return await self._hass.async_add_executor_job(ApiClient, configuration)
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==4.2.0"]
"requirements": ["forecast-solar==5.0.0"]
}

View File

@@ -63,6 +63,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
host=self.config_entry.data[CONF_HOST],
user=self.config_entry.data[CONF_USERNAME],
password=self.config_entry.data[CONF_PASSWORD],
timeout=20,
)
try:

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.19"],
"requirements": ["pyfritzhome==0.6.20"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -1,7 +1,7 @@
{
"domain": "fritzbox_callmonitor",
"name": "FRITZ!Box Call Monitor",
"codeowners": ["@cdce8p"],
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
"integration_type": "device",

View File

@@ -18,7 +18,7 @@ from yarl import URL
from homeassistant.components import onboarding, websocket_api
from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.components.websocket_api import ERR_NOT_FOUND, ActiveConnection
from homeassistant.config import async_hass_config_yaml
from homeassistant.const import (
CONF_MODE,
@@ -78,6 +78,16 @@ THEMES_STORAGE_VERSION = 1
THEMES_SAVE_DELAY = 60
DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store")
DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes")
PANELS_STORAGE_KEY = f"{DOMAIN}_panels"
PANELS_STORAGE_VERSION = 1
PANELS_SAVE_DELAY = 10
DATA_PANELS_STORE: HassKey[Store[dict[str, dict[str, Any]]]] = HassKey(
"frontend_panels_store"
)
DATA_PANELS_CONFIG: HassKey[dict[str, dict[str, Any]]] = HassKey(
"frontend_panels_config"
)
DATA_DEFAULT_THEME = "frontend_default_theme"
DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme"
DEFAULT_THEME = "default"
@@ -312,9 +322,11 @@ class Panel:
self.sidebar_default_visible = sidebar_default_visible
@callback
def to_response(self) -> PanelResponse:
def to_response(
self, config_override: dict[str, Any] | None = None
) -> PanelResponse:
"""Panel as dictionary."""
return {
response: PanelResponse = {
"component_name": self.component_name,
"icon": self.sidebar_icon,
"title": self.sidebar_title,
@@ -324,6 +336,18 @@ class Panel:
"require_admin": self.require_admin,
"config_panel_domain": self.config_panel_domain,
}
if config_override:
if "require_admin" in config_override:
response["require_admin"] = config_override["require_admin"]
if config_override.get("show_in_sidebar") is False:
response["title"] = None
response["icon"] = None
else:
if "icon" in config_override:
response["icon"] = config_override["icon"]
if "title" in config_override:
response["title"] = config_override["title"]
return response
@bind_hass
@@ -415,12 +439,24 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the serving of the frontend."""
await async_setup_frontend_storage(hass)
panels_store = hass.data[DATA_PANELS_STORE] = Store[dict[str, dict[str, Any]]](
hass, PANELS_STORAGE_VERSION, PANELS_STORAGE_KEY
)
loaded: Any = await panels_store.async_load()
if not isinstance(loaded, dict):
if loaded is not None:
_LOGGER.warning("Ignoring invalid panel storage data")
loaded = {}
hass.data[DATA_PANELS_CONFIG] = loaded
websocket_api.async_register_command(hass, websocket_get_icons)
websocket_api.async_register_command(hass, websocket_get_panels)
websocket_api.async_register_command(hass, websocket_get_themes)
websocket_api.async_register_command(hass, websocket_get_translations)
websocket_api.async_register_command(hass, websocket_get_version)
websocket_api.async_register_command(hass, websocket_subscribe_extra_js)
websocket_api.async_register_command(hass, websocket_update_panel)
hass.http.register_view(ManifestJSONView())
conf = config.get(DOMAIN, {})
@@ -559,6 +595,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
async_register_built_in_panel(hass, "profile")
async_register_built_in_panel(hass, "notfound")
@callback
def async_change_listener(
@@ -883,11 +920,18 @@ def websocket_get_panels(
) -> None:
"""Handle get panels command."""
user_is_admin = connection.user.is_admin
panels = {
panel_key: panel.to_response()
for panel_key, panel in connection.hass.data[DATA_PANELS].items()
if user_is_admin or not panel.require_admin
}
panels_config = hass.data[DATA_PANELS_CONFIG]
panels: dict[str, PanelResponse] = {}
for panel_key, panel in connection.hass.data[DATA_PANELS].items():
config_override = panels_config.get(panel_key)
require_admin = (
config_override.get("require_admin", panel.require_admin)
if config_override
else panel.require_admin
)
if not user_is_admin and require_admin:
continue
panels[panel_key] = panel.to_response(config_override)
connection.send_message(websocket_api.result_message(msg["id"], panels))
@@ -986,6 +1030,50 @@ def websocket_subscribe_extra_js(
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/update_panel",
vol.Required("url_path"): str,
vol.Optional("title"): vol.Any(cv.string, None),
vol.Optional("icon"): vol.Any(cv.icon, None),
vol.Optional("require_admin"): vol.Any(cv.boolean, None),
vol.Optional("show_in_sidebar"): vol.Any(cv.boolean, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_update_panel(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle update panel command."""
url_path: str = msg["url_path"]
if url_path not in hass.data.get(DATA_PANELS, {}):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Panel not found")
return
panels_config = hass.data[DATA_PANELS_CONFIG]
panel_config = dict(panels_config.get(url_path, {}))
for key in ("title", "icon", "require_admin", "show_in_sidebar"):
if key in msg:
if (value := msg[key]) is None:
panel_config.pop(key, None)
else:
panel_config[key] = value
if panel_config:
panels_config[url_path] = panel_config
else:
panels_config.pop(url_path, None)
hass.data[DATA_PANELS_STORE].async_delay_save(
lambda: hass.data[DATA_PANELS_CONFIG], PANELS_SAVE_DELAY
)
hass.bus.async_fire(EVENT_PANELS_UPDATED)
connection.send_result(msg["id"])
class PanelResponse(TypedDict):
"""Represent the panel response type."""

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==16.0.1", "Pillow==12.0.0"]
"requirements": ["av==16.0.1", "Pillow==12.1.1"]
}

View File

@@ -58,11 +58,12 @@ async def async_setup_entry(
class GeonetnzVolcanoSensor(SensorEntity):
"""Represents an external event with GeoNet NZ Volcano feed data."""
_attr_icon = DEFAULT_ICON
_attr_native_unit_of_measurement = "alert level"
_attr_should_poll = False
def __init__(self, config_entry_id, feed_manager, external_id, unit_system):
"""Initialize entity with data from feed entry."""
self._config_entry_id = config_entry_id
self._feed_manager = feed_manager
self._external_id = external_id
self._attr_unique_id = f"{config_entry_id}_{external_id}"
@@ -71,8 +72,6 @@ class GeonetnzVolcanoSensor(SensorEntity):
self._distance = None
self._latitude = None
self._longitude = None
self._attribution = None
self._alert_level = None
self._activity = None
self._hazards = None
self._feed_last_update = None
@@ -124,7 +123,7 @@ class GeonetnzVolcanoSensor(SensorEntity):
self._latitude = round(feed_entry.coordinates[0], 5)
self._longitude = round(feed_entry.coordinates[1], 5)
self._attr_attribution = feed_entry.attribution
self._alert_level = feed_entry.alert_level
self._attr_native_value = feed_entry.alert_level
self._activity = feed_entry.activity
self._hazards = feed_entry.hazards
self._feed_last_update = dt_util.as_utc(last_update) if last_update else None
@@ -133,25 +132,10 @@ class GeonetnzVolcanoSensor(SensorEntity):
)
@property
def native_value(self):
"""Return the state of the sensor."""
return self._alert_level
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
return DEFAULT_ICON
@property
def name(self) -> str | None:
def name(self) -> str:
"""Return the name of the entity."""
return f"Volcano {self._title}"
@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return "alert level"
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""

View File

@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
"""Initialize a trait for a state."""
super().__init__(hass, state, config)
if state.domain == fan.DOMAIN:
speed_count = min(
FAN_SPEED_MAX_SPEED_COUNT,
round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
),
speed_count = round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
)
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
else:
self._ordered_speed = []
@staticmethod
def supported(domain, features, device_class, _):
@@ -1786,7 +1786,11 @@ class FanSpeedTrait(_Trait):
result.update(
{
"reversible": reversible,
"supportsFanSpeedPercent": True,
# supportsFanSpeedPercent is mutually exclusive with
# availableFanSpeeds, where supportsFanSpeedPercent takes
# precedence. Report it only when step speeds are not
# supported so Google renders a percent slider (1-100%).
"supportsFanSpeedPercent": not self._ordered_speed,
}
)
@@ -1832,10 +1836,12 @@ class FanSpeedTrait(_Trait):
if domain == fan.DOMAIN:
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
response["currentFanSpeedPercent"] = percent
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
if self._ordered_speed:
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
else:
response["currentFanSpeedPercent"] = percent
return response
@@ -1855,7 +1861,7 @@ class FanSpeedTrait(_Trait):
)
if domain == fan.DOMAIN:
if fan_speed := params.get("fanSpeed"):
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
fan_speed_percent = ordered_list_item_to_percentage(
self._ordered_speed, fan_speed
)

View File

@@ -11,5 +11,10 @@
}
}
}
},
"device": {
"google_translate": {
"name": "Google Translate {lang} {tld}"
}
}
}

View File

@@ -19,6 +19,7 @@ from homeassistant.components.tts import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -26,6 +27,7 @@ from .const import (
CONF_TLD,
DEFAULT_LANG,
DEFAULT_TLD,
DOMAIN,
MAP_LANG_TLD,
SUPPORT_LANGUAGES,
SUPPORT_TLD,
@@ -66,6 +68,9 @@ async def async_setup_entry(
class GoogleTTSEntity(TextToSpeechEntity):
"""The Google speech API entity."""
_attr_supported_languages = SUPPORT_LANGUAGES
_attr_supported_options = SUPPORT_OPTIONS
def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None:
"""Init Google TTS service."""
if lang in MAP_LANG_TLD:
@@ -77,20 +82,15 @@ class GoogleTTSEntity(TextToSpeechEntity):
self._attr_name = f"Google Translate {self._lang} {self._tld}"
self._attr_unique_id = config_entry.entry_id
@property
def default_language(self) -> str:
"""Return the default language."""
return self._lang
@property
def supported_languages(self) -> list[str]:
"""Return list of supported languages."""
return SUPPORT_LANGUAGES
@property
def supported_options(self) -> list[str]:
"""Return a list of supported options."""
return SUPPORT_OPTIONS
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Google",
model="Google Translate TTS",
translation_key="google_translate",
translation_placeholders={"lang": self._lang, "tld": self._tld},
)
self._attr_default_language = self._lang
def get_tts_audio(
self, message: str, language: str, options: dict[str, Any] | None = None

View File

@@ -15,7 +15,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DISCOVERY_TIMEOUT
from .const import DISCOVERY_TIMEOUT, DOMAIN
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -52,7 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
_LOGGER.error("Start failed, errno: %d", ex.errno)
return False
_LOGGER.error("Port %s already in use", LISTENING_PORT)
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="port_in_use",
translation_placeholders={"port": LISTENING_PORT},
) from ex
await coordinator.async_config_entry_first_refresh()
@@ -61,7 +65,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
while not coordinator.devices:
await asyncio.sleep(delay=1)
except TimeoutError as ex:
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="no_devices_found"
) from ex
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -33,5 +33,13 @@
}
}
}
},
"exceptions": {
"no_devices_found": {
"message": "[%key:common::config_flow::abort::no_devices_found%]"
},
"port_in_use": {
"message": "Port {port} is already in use"
}
}
}

View File

@@ -7,6 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.4.2"]
}

View File

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

View File

@@ -4,16 +4,16 @@
"abort": {
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information.",
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"not_hassio_thread": "The OpenThread Border Router app can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router app is already running, it cannot be installed again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router app. If you use the Thread network, make sure you have alternative border routers. Uninstall the app and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again."
},
"progress": {
"install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
"install_otbr_addon": "Installing add-on",
"start_otbr_addon": "Starting add-on"
"install_otbr_addon": "Installing app",
"start_otbr_addon": "Starting app"
},
"step": {
"confirm_otbr": {
@@ -34,7 +34,7 @@
"title": "Updating adapter"
},
"otbr_failed": {
"description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists.",
"description": "The OpenThread Border Router app installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other apps, and try again. Check the Supervisor logs if the problem persists.",
"title": "Failed to set up OpenThread Border Router"
},
"pick_firmware": {
@@ -89,11 +89,11 @@
"silabs_multiprotocol_hardware": {
"options": {
"abort": {
"addon_already_running": "Failed to start the {addon_name} add-on because it is already running.",
"addon_info_failed": "Failed to get {addon_name} add-on info.",
"addon_install_failed": "Failed to install the {addon_name} add-on.",
"addon_already_running": "Failed to start the {addon_name} app because it is already running.",
"addon_info_failed": "Failed to get {addon_name} app info.",
"addon_install_failed": "Failed to install the {addon_name} app.",
"addon_set_config_failed": "Failed to set {addon_name} configuration.",
"addon_start_failed": "Failed to start the {addon_name} add-on.",
"addon_start_failed": "Failed to start the {addon_name} app.",
"not_hassio": "The hardware options can only be configured on Home Assistant OS installations.",
"zha_migration_failed": "The ZHA migration did not succeed."
},
@@ -101,8 +101,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"progress": {
"install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds."
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
},
"step": {
"addon_installed_other_device": {
@@ -129,7 +129,7 @@
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"install_addon": {
"title": "The Silicon Labs Multiprotocol add-on installation has started"
"title": "The Silicon Labs Multiprotocol app installation has started"
},
"notify_channel_change": {
"description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes.",
@@ -143,7 +143,7 @@
"title": "Reconfigure IEEE 802.15.4 radio multiprotocol support"
},
"start_addon": {
"title": "The Silicon Labs Multiprotocol add-on is starting."
"title": "The Silicon Labs Multiprotocol app is starting."
},
"uninstall_addon": {
"data": {

View File

@@ -25,7 +25,7 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"read_hw_settings_error": "Failed to read hardware settings",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device.",
"write_hw_settings_error": "Failed to write hardware settings",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]"

View File

@@ -11,7 +11,12 @@ from pyHomee import (
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -113,7 +118,22 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_address")
await self.async_set_unique_id(self._name)
# If an already configured homee reports with a second IP, abort.
existing_entry = await self.async_set_unique_id(self._name)
if (
existing_entry
and existing_entry.state == ConfigEntryState.LOADED
and existing_entry.runtime_data.connected
and existing_entry.data[CONF_HOST] != self._host
):
_LOGGER.debug(
"Aborting config flow for discovered homee with IP %s "
"since it is already configured at IP %s",
self._host,
existing_entry.data[CONF_HOST],
)
return self.async_abort(reason="2nd_ip_address")
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
# Cause an auth-error to see if homee is reachable.

View File

@@ -20,6 +20,7 @@ PARALLEL_UPDATES = 0
REMOTE_PROFILES = [
NodeProfile.REMOTE,
NodeProfile.ONE_BUTTON_REMOTE,
NodeProfile.TWO_BUTTON_REMOTE,
NodeProfile.THREE_BUTTON_REMOTE,
NodeProfile.FOUR_BUTTON_REMOTE,

View File

@@ -1,6 +1,7 @@
{
"config": {
"abort": {
"2nd_ip_address": "Your homee is already connected using another IP address",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",

View File

@@ -3,6 +3,7 @@
from datetime import datetime
from functools import partial
import logging
from typing import Any
from pyhomematic import HMConnection
import voluptuous as vol
@@ -215,8 +216,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_CONF] = remotes = {}
hass.data[DATA_STORE] = set()
interfaces: dict[str, dict[str, Any]] = conf[CONF_INTERFACES]
hosts: dict[str, dict[str, Any]] = conf[CONF_HOSTS]
# Create hosts-dictionary for pyhomematic
for rname, rconfig in conf[CONF_INTERFACES].items():
for rname, rconfig in interfaces.items():
remotes[rname] = {
"ip": rconfig.get(CONF_HOST),
"port": rconfig.get(CONF_PORT),
@@ -232,7 +236,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"connect": True,
}
for sname, sconfig in conf[CONF_HOSTS].items():
for sname, sconfig in hosts.items():
remotes[sname] = {
"ip": sconfig.get(CONF_HOST),
"port": sconfig[CONF_PORT],
@@ -258,7 +262,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop)
# Init homematic hubs
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in conf[CONF_HOSTS]]
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in hosts]
def _hm_service_virtualkey(service: ServiceCall) -> None:
"""Service to handle virtualkey servicecalls."""
@@ -294,7 +298,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
def _service_handle_value(service: ServiceCall) -> None:
"""Service to call setValue method for HomeMatic system variable."""
entity_ids = service.data.get(ATTR_ENTITY_ID)
entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID)
name = service.data[ATTR_NAME]
value = service.data[ATTR_VALUE]

View File

@@ -11,6 +11,7 @@ from pyhomematic import HMConnection
from pyhomematic.devicetypes.generic import HMGeneric
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.event import track_time_interval
@@ -45,15 +46,16 @@ class HMDevice(Entity):
entity_description: EntityDescription | None = None,
) -> None:
"""Initialize a generic HomeMatic device."""
self._name = config.get(ATTR_NAME)
self._attr_name = config.get(ATTR_NAME)
self._address = config.get(ATTR_ADDRESS)
self._interface = config.get(ATTR_INTERFACE)
self._channel = config.get(ATTR_CHANNEL)
self._state = config.get(ATTR_PARAM)
self._unique_id = config.get(ATTR_UNIQUE_ID)
if unique_id := config.get(ATTR_UNIQUE_ID):
self._attr_unique_id = unique_id.replace(" ", "_")
self._data: dict[str, Any] = {}
self._connected = False
self._available = False
self._attr_available = False
self._channel_map: dict[str, str] = {}
if entity_description is not None:
@@ -67,21 +69,6 @@ class HMDevice(Entity):
"""Load data init callbacks."""
self._subscribe_homematic_events()
@property
def unique_id(self):
"""Return unique ID. HomeMatic entity IDs are unique by default."""
return self._unique_id.replace(" ", "_")
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def available(self) -> bool:
"""Return true if device is available."""
return self._available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device specific state attributes."""
@@ -116,7 +103,7 @@ class HMDevice(Entity):
self._load_data_from_hm()
# Link events from pyhomematic
self._available = not self._hmdevice.UNREACH
self._attr_available = not self._hmdevice.UNREACH
except Exception as err: # noqa: BLE001
self._connected = False
_LOGGER.error("Exception while linking %s: %s", self._address, str(err))
@@ -132,7 +119,7 @@ class HMDevice(Entity):
# Availability has changed
if self.available != (not self._hmdevice.UNREACH):
self._available = not self._hmdevice.UNREACH
self._attr_available = not self._hmdevice.UNREACH
has_changed = True
# If it has changed data point, update Home Assistant
@@ -213,14 +200,14 @@ class HMHub(Entity):
_attr_should_poll = False
def __init__(self, hass, homematic, name):
def __init__(self, hass: HomeAssistant, homematic: HMConnection, name: str) -> None:
"""Initialize HomeMatic hub."""
self.hass = hass
self.entity_id = f"{DOMAIN}.{name.lower()}"
self._homematic = homematic
self._variables = {}
self._variables: dict[str, Any] = {}
self._name = name
self._state = None
self._state: int | None = None
# Load data
track_time_interval(self.hass, self._update_hub, SCAN_INTERVAL_HUB)
@@ -230,12 +217,12 @@ class HMHub(Entity):
self.hass.add_job(self._update_variables, None)
@property
def name(self):
def name(self) -> str:
"""Return the name of the device."""
return self._name
@property
def state(self):
def state(self) -> int | None:
"""Return the state of the entity."""
return self._state
@@ -245,7 +232,7 @@ class HMHub(Entity):
return self._variables.copy()
@property
def icon(self):
def icon(self) -> str:
"""Return the icon to use in the frontend, if any."""
return "mdi:gradient-vertical"

View File

@@ -344,4 +344,4 @@ class HMSensor(HMDevice, SensorEntity):
if self._state:
self._data.update({self._state: None})
else:
_LOGGER.critical("Unable to initialize sensor: %s", self._name)
_LOGGER.critical("Unable to initialize sensor: %s", self.name)

View File

@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,67 @@
"""Shared entity helpers for Homevolt."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltDataUpdateCoordinator
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
"""Base Homevolt entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
) -> None:
"""Initialize the Homevolt entity."""
super().__init__(coordinator)
device_id = coordinator.data.unique_id
device_metadata = coordinator.data.device_metadata.get(device_identifier)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Homevolt calls to handle exceptions."""
async def handler(
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except HomevoltAuthenticationError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from error
except HomevoltConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["homevolt==0.4.4"],
"requirements": ["homevolt==0.5.0"],
"zeroconf": [
{
"name": "homevolt*",

View File

@@ -22,13 +22,11 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity
PARALLEL_UPDATES = 0 # Coordinator-based updates
@@ -309,11 +307,10 @@ async def async_setup_entry(
async_add_entities(entities)
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
class HomevoltSensor(HomevoltEntity, SensorEntity):
"""Representation of a Homevolt sensor."""
entity_description: SensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -322,24 +319,12 @@ class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEnt
sensor_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
unique_id = coordinator.data.unique_id
self._attr_unique_id = f"{unique_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
super().__init__(coordinator, sensor_data.device_identifier)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{sensor_key}"
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
@property
def available(self) -> bool:
"""Return if entity is available."""

View File

@@ -160,6 +160,22 @@
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
},
"exceptions": {
"auth_failed": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"communication_error": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"unknown_error": {
"message": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -0,0 +1,55 @@
"""Support for Homevolt switch entities."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt switch entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
"""Switch entity for Homevolt local mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "local_mode"
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the switch entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def is_on(self) -> bool:
"""Return true if local mode is enabled."""
return self.coordinator.client.local_mode_enabled
@homevolt_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable local mode."""
await self.coordinator.client.enable_local_mode()
await self.coordinator.async_request_refresh()
@homevolt_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable local mode."""
await self.coordinator.client.disable_local_mode()
await self.coordinator.async_request_refresh()

View File

@@ -6,6 +6,7 @@ from enum import Enum
import logging
from typing import Any
from bleak.backends.scanner import AdvertisementData
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
import voluptuous as vol
@@ -26,6 +27,17 @@ from .light import get_available_color_modes
_LOGGER = logging.getLogger(__name__)
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
def device_filter(advertisement_data: AdvertisementData) -> bool:
"""Return True if the device is supported."""
return (
SERVICE_UUID in advertisement_data.service_uuids
and SERVICE_DATA_UUID in advertisement_data.service_data
)
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
"""Return error if cannot connect and validate."""
@@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
unique_id = dr.format_mac(user_input[CONF_MAC])
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in bluetooth.async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not device_filter(discovery.advertisement)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
data_schema = vol.Schema(
{
vol.Required(CONF_MAC): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a flow initialized by the home assistant scanner."""
_LOGGER.debug(
"HA found light %s. Will show in UI but not auto connect",
"HA found light %s. Use user flow to show in UI and connect",
discovery_info.name,
)
unique_id = dr.format_mac(discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
name = f"{discovery_info.name} ({discovery_info.address})"
self.context.update({"title_placeholders": {CONF_NAME: name}})
self._discovery_info = discovery_info
return await self.async_step_confirm()
return self.async_abort(reason="discovery_unsupported")
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -103,7 +153,10 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
unique_id = dr.format_mac(self._discovery_info.address)
await self.async_set_unique_id(unique_id)
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
error = await validate_input(self.hass, unique_id)
if error:

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_implemented": "This integration can only be set up via discovery."
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -14,7 +15,16 @@
},
"step": {
"confirm": {
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
},
"user": {
"data": {
"mac": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"mac": "Select the Hue device you want to set up"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.3.0"]
"requirements": ["pyicloud==2.4.1"]
}

View File

@@ -329,14 +329,14 @@ class IDriveE2BackupAgent(BackupAgent):
return self._backup_cache
backups = {}
response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket)
# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
for metadata_file in metadata_files:
try:

View File

@@ -68,7 +68,7 @@ class IGloLamp(LightEntity):
return self._name
@property
def brightness(self):
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return int((self._lamp.state()["brightness"] / 200.0) * 255)
@@ -97,17 +97,17 @@ class IGloLamp(LightEntity):
return self._lamp.min_kelvin
@property
def hs_color(self):
def hs_color(self) -> tuple[float, float]:
"""Return the hs value."""
return color_util.color_RGB_to_hs(*self._lamp.state()["rgb"])
@property
def effect(self):
def effect(self) -> str:
"""Return the current effect."""
return self._lamp.state()["effect"]
@property
def effect_list(self):
def effect_list(self) -> list[str]:
"""Return the list of supported effects."""
return self._lamp.effect_list()

View File

@@ -1,6 +1,7 @@
"""Implementation of the lock platform."""
from datetime import timedelta
from typing import Any
from aiohttp import ClientError
from igloohome_api import (
@@ -63,7 +64,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
)
self.bridge_id = bridge_id
async def async_lock(self, **kwargs):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock this lock."""
try:
await self.api.create_bridge_proxied_job(
@@ -72,7 +73,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
except (ApiException, ClientError) as err:
raise HomeAssistantError from err
async def async_unlock(self, **kwargs):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock this lock."""
try:
await self.api.create_bridge_proxied_job(
@@ -81,7 +82,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
except (ApiException, ClientError) as err:
raise HomeAssistantError from err
async def async_open(self, **kwargs):
async def async_open(self, **kwargs: Any) -> None:
"""Open (unlatch) this lock."""
try:
await self.api.create_bridge_proxied_job(

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==12.0.0"]
"requirements": ["Pillow==12.1.1"]
}

View File

@@ -310,7 +310,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
return self._config[CONF_HAS_TIME]
@property
def icon(self):
def icon(self) -> str | None:
"""Return the icon to be used for this entity."""
return self._config.get(CONF_ICON)

View File

@@ -243,7 +243,7 @@ class InputNumber(collection.CollectionEntity, RestoreEntity):
return self._config.get(CONF_NAME)
@property
def icon(self):
def icon(self) -> str | None:
"""Return the icon to be used for this entity."""
return self._config.get(CONF_ICON)

View File

@@ -61,7 +61,7 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity):
self._attr_supported_color_modes = {ColorMode.ONOFF}
@property
def brightness(self):
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return self._insteon_device_group.value

View File

@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import LOGGER
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
PLATFORMS = [Platform.FAN]
PLATFORMS = [Platform.FAN, Platform.SELECT]
async def async_setup_entry(

View File

@@ -27,8 +27,6 @@ class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
"""Class initializer."""
super().__init__(coordinator=coordinator)
self._attr_unique_id = device.id
# Make this HA "device" use the IntelliClima device name.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},

View File

@@ -62,6 +62,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
super().__init__(coordinator, device)
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
self._attr_unique_id = device.id
@property
def is_on(self) -> bool:

View File

@@ -49,7 +49,7 @@ rules:
comment: |
Unclear if discovery is possible.
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -0,0 +1,96 @@
"""Select platform for IntelliClima VMC."""
from pyintelliclima.const import FanMode, FanSpeed
from pyintelliclima.intelliclima_types import IntelliClimaECO
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
from .entity import IntelliClimaECOEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
FAN_MODE_TO_INTELLICLIMA_MODE = {
"forward": FanMode.inward,
"reverse": FanMode.outward,
"alternate": FanMode.alternate,
"sensor": FanMode.sensor,
}
INTELLICLIMA_MODE_TO_FAN_MODE = {v: k for k, v in FAN_MODE_TO_INTELLICLIMA_MODE.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: IntelliClimaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IntelliClima VMC fan mode select."""
coordinator = entry.runtime_data
entities: list[IntelliClimaVMCFanModeSelect] = [
IntelliClimaVMCFanModeSelect(
coordinator=coordinator,
device=ecocomfort2,
)
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
]
async_add_entities(entities)
class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
"""Representation of an IntelliClima VMC fan mode selector."""
_attr_translation_key = "fan_mode"
_attr_options = ["forward", "reverse", "alternate", "sensor"]
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self._attr_unique_id = f"{device.id}_fan_mode"
@property
def current_option(self) -> str | None:
"""Return the current fan mode."""
device_data = self._device_data
if device_data.mode_set == FanMode.off:
return None
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
if (
device_data.speed_set == FanSpeed.auto
and device_data.mode_set == FanMode.sensor
):
return None
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
async def async_select_option(self, option: str) -> None:
"""Set the fan mode."""
device_data = self._device_data
mode = FAN_MODE_TO_INTELLICLIMA_MODE[option]
# Determine speed: keep current speed if available, otherwise default to sleep
if (
device_data.speed_set == FanSpeed.auto
or device_data.mode_set == FanMode.off
):
speed = FanSpeed.sleep
else:
speed = device_data.speed_set
await self.coordinator.api.ecocomfort.set_mode_speed(
self._device_sn, mode, speed
)
await self.coordinator.async_request_refresh()

View File

@@ -22,5 +22,18 @@
"description": "Authenticate against IntelliClima cloud"
}
}
},
"entity": {
"select": {
"fan_mode": {
"name": "Fan direction mode",
"state": {
"alternate": "Alternating",
"forward": "Forward",
"reverse": "Reverse",
"sensor": "Sensor"
}
}
}
}
}

View File

@@ -451,7 +451,7 @@ class AirPlayDevice(MediaPlayerEntity):
return self.device_name
@property
def icon(self):
def icon(self) -> str:
"""Return the icon to use in the frontend, if any."""
if self.selected is True:
return "mdi:volume-high"

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.1.1"],
"requirements": ["pykaleidescape==1.1.3"],
"ssdp": [
{
"deviceType": "schemas-upnp-org:device:Basic:1",

View File

@@ -6,7 +6,11 @@ import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from .const import DOMAIN
from .coordinator import (
@@ -80,6 +84,21 @@ async def async_setup_entry(
lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry)
await lhm_coordinator.async_config_entry_first_refresh()
if lhm_coordinator.data.is_deprecated_version:
issue_id = f"deprecated_api_{config_entry.entry_id}"
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version="2026.9.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_api",
translation_placeholders={
"lhm_releases_url": "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases"
},
)
config_entry.runtime_data = lhm_coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -50,7 +50,7 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
config_entry=config_entry,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self._entry_id = config_entry.entry_id
self._api = LibreHardwareMonitorClient(
host=config_entry.data[CONF_HOST],
port=config_entry.data[CONF_PORT],
@@ -59,13 +59,14 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
session=async_create_clientsession(hass),
)
device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
registry=dr.async_get(self.hass), config_entry_id=self._entry_id
)
self._previous_devices: dict[DeviceId, DeviceName] = {
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
for device in device_entries
if device.identifiers and device.name
}
self._is_deprecated_version: bool | None = None
async def _async_update_data(self) -> LibreHardwareMonitorData:
try:
@@ -80,6 +81,12 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
except LibreHardwareMonitorNoDevicesError as err:
raise UpdateFailed("No sensor data available, will retry") from err
# Check whether user has upgraded LHM from a deprecated version while the integration is running
if self._is_deprecated_version and not lhm_data.is_deprecated_version:
# Clear deprecation issue
ir.async_delete_issue(self.hass, DOMAIN, f"deprecated_api_{self._entry_id}")
self._is_deprecated_version = lhm_data.is_deprecated_version
await self._async_handle_changes_in_devices(
dict(lhm_data.main_device_ids_and_names)
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.9.1"]
"requirements": ["librehardwaremonitor-api==1.10.1"]
}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
from librehardwaremonitor_api.sensor_type import SensorType
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant, callback
@@ -53,12 +54,8 @@ class LibreHardwareMonitorSensor(
super().__init__(coordinator)
self._attr_name: str = sensor_data.name
self._attr_native_value: str | None = sensor_data.value
self._attr_extra_state_attributes: dict[str, Any] = {
STATE_MIN_VALUE: sensor_data.min,
STATE_MAX_VALUE: sensor_data.max,
}
self._attr_native_unit_of_measurement = sensor_data.unit
self._set_state(coordinator.data.is_deprecated_version, sensor_data)
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
self._sensor_id: str = sensor_data.sensor_id
@@ -70,15 +67,36 @@ class LibreHardwareMonitorSensor(
model=sensor_data.device_type,
)
def _set_state(
self,
is_deprecated_lhm_version: bool,
sensor_data: LibreHardwareMonitorSensorData,
) -> None:
value = sensor_data.value
min_value = sensor_data.min
max_value = sensor_data.max
unit = sensor_data.unit
if not is_deprecated_lhm_version and sensor_data.type == SensorType.THROUGHPUT:
# Temporary fix: convert the B/s value to KB/s to not break existing entries
# This will be migrated properly once SensorDeviceClass is introduced
value = f"{(float(value) / 1024):.1f}" if value else None
min_value = f"{(float(min_value) / 1024):.1f}" if min_value else None
max_value = f"{(float(max_value) / 1024):.1f}" if max_value else None
unit = "KB/s"
self._attr_native_value: str | None = value
self._attr_extra_state_attributes: dict[str, Any] = {
STATE_MIN_VALUE: min_value,
STATE_MAX_VALUE: max_value,
}
self._attr_native_unit_of_measurement = unit
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
self._attr_native_value = sensor_data.value
self._attr_extra_state_attributes = {
STATE_MIN_VALUE: sensor_data.min,
STATE_MAX_VALUE: sensor_data.max,
}
self._set_state(self.coordinator.data.is_deprecated_version, sensor_data)
else:
self._attr_native_value = None

View File

@@ -33,5 +33,11 @@
}
}
}
},
"issues": {
"deprecated_api": {
"description": "Your version of Libre Hardware Monitor is deprecated and may not provide stable sensor data. To fix this issue:\n\n1. Download version 0.9.5 or later from {lhm_releases_url}\n2. Close Libre Hardware Monitor on your computer\n3. Install or extract the new version and start Libre Hardware Monitor again (you might have to re-enable the remote web server)\n4. Home Assistant will detect the new version and this issue will clear automatically",
"title": "Deprecated Libre Hardware Monitor version"
}
}
}

View File

@@ -1,4 +1,4 @@
"""The liebherr integration."""
"""The Liebherr integration."""
from __future__ import annotations
@@ -17,7 +17,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:

View File

@@ -1,5 +1,55 @@
{
"entity": {
"select": {
"bio_fresh_plus": {
"default": "mdi:leaf"
},
"bio_fresh_plus_bottom_zone": {
"default": "mdi:leaf"
},
"bio_fresh_plus_middle_zone": {
"default": "mdi:leaf"
},
"bio_fresh_plus_top_zone": {
"default": "mdi:leaf"
},
"hydro_breeze": {
"default": "mdi:weather-windy"
},
"hydro_breeze_bottom_zone": {
"default": "mdi:weather-windy"
},
"hydro_breeze_middle_zone": {
"default": "mdi:weather-windy"
},
"hydro_breeze_top_zone": {
"default": "mdi:weather-windy"
},
"ice_maker": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_bottom_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_middle_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_top_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
}
},
"switch": {
"night_mode": {
"default": "mdi:sleep",

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