Compare commits

..

238 Commits

Author SHA1 Message Date
abmantis
f296a215e7 Use hass from init 2025-10-02 17:50:34 +01:00
abmantis
4b5fd38849 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-10-02 17:25:00 +01:00
Joost Lekkerkerker
ee4a1de566 Add translation for turbo fan mode in SmartThings (#153445)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 18:45:37 +03:00
epenet
7ab99c028c Add new test fixture for Tuya wk category (#153457) 2025-10-02 17:29:14 +02:00
TheJulianJES
0e1d12b1ae Fix Z-Wave RGB light turn on causing rare ZeroDivisionError (#153422) 2025-10-02 17:26:49 +02:00
abmantis
308f6eb5a8 Improve doc 2025-10-02 16:22:17 +01:00
abmantis
82f1ae3519 Rename class; add doc 2025-10-02 16:19:41 +01:00
Artur Pragacz
e090ddd761 Move entities to the end of devices in analytics payload (#153449) 2025-10-02 16:36:38 +02:00
Stefan Agner
9721ce6877 Update Home Assistant base image to 2025.10.0 (#153441) 2025-10-02 15:17:59 +02:00
MoonDevLT
8dde94f421 Add Lunatone gateway integration (#149182)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 12:55:17 +01:00
dollaransh17
f5f6b22af1 Fix spelling error in logbook tests (#153417)
Co-authored-by: dollaransh17 <dollaransh17@users.noreply.github.com>
2025-10-02 12:49:22 +02:00
Tom Matheussen
f8a93b6561 Add Quality Scale to Satel Integra (#153122) 2025-10-02 12:48:34 +02:00
epenet
840a03f048 Add new dehumidifier fixture for Tuya (#153407) 2025-10-02 12:15:28 +02:00
Erwin Douna
85f3b5ce78 Firefly III add re-auth flow (#153303)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-10-02 12:15:10 +02:00
Michael
f4284fec2f Explicit pass in the config entry to coordinator in airtouch4 (#153361)
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-02 11:54:20 +02:00
Abílio Costa
3a89b3152f Move common Uptime Robot new device check logic to helper (#153094)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 10:52:22 +01:00
epenet
a0356328c3 Use walrus and combine conditions in Tuya alarm control panel (#153426) 2025-10-02 11:52:08 +02:00
Michael J. Kidd
4b6f37b1d7 Pushover: Handle empty data section properly (#153397) 2025-10-02 11:48:03 +02:00
johanzander
716705fb5a Adds token authentication and usage of official API for Growatt MIN/TLX inverters (#149783) 2025-10-02 11:40:53 +02:00
J. Nick Koston
d246836480 Bump aiohomekit to 3.2.19 (#153423) 2025-10-02 11:17:17 +02:00
Guido Schmitz
6ee2b82d15 Cleanup sync_callback in devolo Home Control (#153321) 2025-10-02 11:11:19 +02:00
Luke Lashley
73ff8d36a5 Bump python-roborock to 2.49.1 (#153396) 2025-10-02 11:09:44 +02:00
Manu
1397def3b8 Add last check-in sensor to Habitica integration (#153293) 2025-10-02 11:03:21 +02:00
epenet
d443529041 Add more sensors to Tuya weather monitor (#153420) 2025-10-02 10:58:24 +02:00
G Johansson
373bb20f1b Remove deprecated entity feature constants in vacuum (#153364) 2025-10-02 10:46:34 +02:00
Erik Montnemery
3b44cce6dc Improve recorder migration test (#153405) 2025-10-02 10:45:04 +02:00
Joakim Plate
46056fe45b Correct blocking update in ToGrill with lack of notifications (#153387) 2025-10-02 10:44:42 +02:00
epenet
1816c190b2 Add test fixture for new Tuya cjkg category (#153411) 2025-10-02 10:43:54 +02:00
Josef Zweck
00abaee6b3 Increase onedrive upload chunk size (#153406) 2025-10-02 10:43:10 +02:00
Manu
3a301f54e0 Update markdown field description in ntfy integration (#153421) 2025-10-02 10:40:33 +02:00
Denis Shulyaka
762accbd6d Disable thinking for unsupported gemini models (#153415) 2025-10-02 10:38:31 +02:00
Tom Matheussen
e0422d7d34 Fix Satel Integra creating new binary sensors on YAML import (#153419) 2025-10-02 10:37:41 +02:00
Erwin Douna
6ba2057a88 Bump pyportainer 1.0.3 (#153413) 2025-10-02 10:34:11 +02:00
epenet
752969bce5 Add test fixture for new Tuya jsq category (#153412) 2025-10-02 10:33:02 +02:00
Franck Nijhof
efbdfd2954 Merge branch 'master' into dev 2025-10-02 07:06:58 +00:00
Erik Montnemery
bb7a177a5d Improve recorder migration tests (#153388) 2025-10-02 07:45:04 +02:00
Kinachi249
9b56ca8cde Bump PyCync to 0.4.1 (#153401) 2025-10-02 07:11:34 +02:00
abmantis
b8660b4248 Rename callback type 2025-10-01 22:12:51 +01:00
abmantis
fba50af1c3 Replace wrapper with builder method 2025-10-01 22:04:34 +01:00
starkillerOG
b0a08782e0 Add Roborock mop intensity translations (#153380) 2025-10-01 22:51:26 +02:00
G Johansson
6c9955f220 Remove deprecated constants in camera (#153363) 2025-10-01 22:20:34 +02:00
G Johansson
f56b94c0f9 Remove deprecated constants from media_player (#153366) 2025-10-01 22:20:07 +02:00
G Johansson
3cf035820b Remove deprecated state constants from lock (#153367) 2025-10-01 22:16:52 +02:00
Erik Montnemery
99a796d066 Remove legacy history queries from recorder (#153324) 2025-10-01 22:06:56 +02:00
Erik Montnemery
1cd1b1aba8 Remove to_native method from recorder database schemas (#153334) 2025-10-01 21:25:05 +02:00
Ståle Storø Hauknes
4131c14629 Add parallel updates to airthings_ble (#153315) 2025-10-01 20:14:23 +02:00
Tom
c2acda5796 Bump airOS module for alternative login url (#153317) 2025-10-01 20:11:35 +02:00
Marc Mueller
4806e7e9d9 Update cryptography to 46.0.2 (#153327) 2025-10-01 19:52:57 +02:00
Marc Mueller
76606fd44f Update types packages (#153330) 2025-10-01 19:51:37 +02:00
Andre Lengwenus
2983f1a3b6 Explicitly check for None in raw value processing of modbus (#153352) 2025-10-01 19:48:35 +02:00
Michael
8019779b3a Set config entry to None in ProxmoxVE (#153357) 2025-10-01 19:45:34 +02:00
Marc Mueller
62cdcbf422 Misc typing improvements (#153322) 2025-10-01 19:30:41 +02:00
Marc Mueller
b12a5a36e1 Update bcrpyt to 5.0.0 (#153325) 2025-10-01 20:07:45 +03:00
epenet
e32763e464 Add water heater fixture for Tuya tests (#153336) 2025-10-01 20:02:54 +03:00
Stefan Agner
b85cf3f9d2 Bump aiohasupervisor to 0.3.3 (#153344) 2025-10-01 20:01:53 +03:00
puddly
3777bcc2af Do not reset the adapter twice during ZHA options flow migration (#153345) 2025-10-01 18:22:41 +02:00
Franck Nijhof
9a29cc53ef 2025.10.0 (#152881) 2025-10-01 18:17:36 +02:00
Maciej Bieniek
52cde48ff0 Add missing test for Shelly config flow (#153346) 2025-10-01 18:32:57 +03:00
Marc Mueller
bf1da35303 Update pyOpenSSL to 25.3.0 (#153329) 2025-10-01 17:32:08 +02:00
abmantis
bdd448fbe0 Allow overriding trigger runner helper 2025-10-01 16:24:50 +01:00
Franck Nijhof
55d5e769b2 Bump version to 2025.10.0 2025-10-01 15:19:48 +00:00
Erwin Douna
c1bf11da34 Bump pyportainer 1.0.2 (#153326) 2025-10-01 17:07:21 +02:00
Erwin Douna
3c20325b37 Bump pyfirefly 0.1.6 (#153335) 2025-10-01 17:06:31 +02:00
Franck Nijhof
6cd1283b00 Bump version to 2025.10.0b7 2025-10-01 14:51:37 +00:00
Maciej Bieniek
dde60cdecb Improve mac_address_from_name() function to avoid double discovery of Shelly devices (#153343) 2025-10-01 14:51:09 +00:00
Michael Hansen
f03b16bdf8 Bump intents to 2025.10.1 (#153340) 2025-10-01 14:51:07 +00:00
Maciej Bieniek
fd8ccb8d8f Improve mac_address_from_name() function to avoid double discovery of Shelly devices (#153343) 2025-10-01 16:49:27 +02:00
Michael Hansen
d76e947021 Bump intents to 2025.10.1 (#153340) 2025-10-01 09:39:08 -05:00
Erik Montnemery
c91ed96543 Use pytest.mark.usefixtures in history tests (#153306) 2025-10-01 15:53:55 +02:00
HarvsG
b164531ba8 Bayesian - add config entry tests (#153316) 2025-10-01 15:46:16 +02:00
Erik Montnemery
7c623a8704 Use pytest.mark.usefixtures in some recorder tests (#153313) 2025-10-01 15:38:51 +02:00
Maciej Bieniek
7ae3340336 Add test for full device snapshot for Shelly Wall Display XL (#153305) 2025-10-01 16:00:15 +03:00
Marc Mueller
653b73c601 Fix device_automation RuntimeWarning in tests (#153319) 2025-10-01 14:26:09 +02:00
Franck Nijhof
f616e5a4e3 Bump version to 2025.10.0b6 2025-10-01 10:41:01 +00:00
Artur Pragacz
c0317f60cc Add analytics platform to esphome (#153311) 2025-10-01 10:40:41 +00:00
Bram Kragten
8abfe424e1 Update frontend to 20251001.0 (#153300) 2025-10-01 10:40:40 +00:00
HarvsG
8de200de0b Fix Bayesian ConfigFlow templates in 2025.10 (#153289)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-10-01 10:40:38 +00:00
Joost Lekkerkerker
f242e294be Add Konnected brand (#153280) 2025-10-01 10:40:36 +00:00
Joost Lekkerkerker
58cc7c8f84 Add Level brand (#153279) 2025-10-01 10:40:35 +00:00
Joost Lekkerkerker
bd10f6ec08 Require cloud for Aladdin Connect (#153278)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-01 10:40:33 +00:00
puddly
ed9cfb4c4b Use hardware bootloader reset methods for firmware config flows (#153277) 2025-10-01 10:40:32 +00:00
Joost Lekkerkerker
a6b6e4c4b8 Add Eltako brand (#153276) 2025-10-01 10:40:31 +00:00
Robert Resch
36ff5c0d45 Bump aioecowitt to 2025.9.2 (#153273) 2025-10-01 10:40:29 +00:00
Artur Pragacz
de6d34fec5 Filter out service type devices in extended analytics (#153271) 2025-10-01 10:40:28 +00:00
Erwin Douna
38f9067970 Portainer fix CONF_VERIFY_SSL (#153269)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-10-01 10:40:27 +00:00
Norbert Rittel
53a8a250d0 Replace "Climate name" with "Climate program" in ecobee action (#153264) 2025-10-01 10:40:25 +00:00
Artur Pragacz
00f6d26ede Add analytics platform to wled (#153258) 2025-10-01 10:40:24 +00:00
andreimoraru
6d09411c07 Bump yt-dlp to 2025.09.26 (#153252) 2025-10-01 10:40:22 +00:00
TheJulianJES
037e2bfd31 Fix ZHA unable to select "none" flow control (#153235) 2025-10-01 10:40:21 +00:00
TheJulianJES
c893552d4a Replace remaining ZHA "radio" strings with "adapter" (#153234) 2025-10-01 10:40:20 +00:00
TheJulianJES
4fd10162c9 Improve ZHA multi-pan firmware repair text (#153232) 2025-10-01 10:40:18 +00:00
Joris Pelgröm
392ee5ae7e Use UnitOfTime.DAYS instead of custom unit for LetPot number entity (#153054) 2025-10-01 10:40:17 +00:00
puddly
bf190609a0 Reduce Connect firmware install times by removing unnecessary firmware probing (#153012) 2025-10-01 10:40:15 +00:00
Samuel Xiao
e982ac1e53 Switchbot Cloud: Fix Roller Shade not work issue (#152528) 2025-10-01 10:40:14 +00:00
Pete Sage
b4747ea87b Fix Sonos Dialog Select type conversion part II (#152491)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-01 10:40:12 +00:00
HarvsG
df69bcecb7 Pihole better logging of update errors (#152077) 2025-10-01 10:40:11 +00:00
Artur Pragacz
7c93d91bae Filter out service type devices in extended analytics (#153271) 2025-10-01 12:38:50 +02:00
Abílio Costa
07da0cfb2b Stop writing to config dir log file on supervised install (#146675)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-10-01 11:11:00 +01:00
Artur Pragacz
b411a11c2c Add analytics platform to esphome (#153311) 2025-10-01 12:08:50 +02:00
epenet
0555b84d05 Add new cover fixture for Tuya (#153310) 2025-10-01 12:01:37 +02:00
TheJulianJES
790bddef63 Improve ZHA multi-pan firmware repair text (#153232) 2025-10-01 11:50:01 +02:00
TheJulianJES
a3089b8aa7 Replace remaining ZHA "radio" strings with "adapter" (#153234) 2025-10-01 11:46:08 +02:00
puddly
77c8426d63 Use hardware bootloader reset methods for firmware config flows (#153277) 2025-10-01 11:43:28 +02:00
TheJulianJES
faf226f6c2 Fix ZHA unable to select "none" flow control (#153235) 2025-10-01 11:42:50 +02:00
HarvsG
06d143b81a Fix Bayesian ConfigFlow templates in 2025.10 (#153289)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-10-01 11:39:23 +02:00
Erik Montnemery
08b6a0a702 Add device class filter to switcher_kis services (#153248) 2025-10-01 12:27:17 +03:00
Bram Kragten
a20d1e3656 Update frontend to 20251001.0 (#153300) 2025-10-01 09:50:30 +02:00
Erwin Douna
36cc3682ca Add Firefly III integration (#147062)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-30 23:34:33 +02:00
Aviad Levy
1b495ecafa Add support for errored torrents in qBittorrent sensor (#153120)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-09-30 23:34:15 +02:00
puddly
7d1a0be07e Reduce Connect firmware install times by removing unnecessary firmware probing (#153012) 2025-09-30 22:41:51 +02:00
Geoffrey
327f65c991 Add switch domain to VegeHub integration (#148436)
Co-authored-by: GhoweVege <85890024+GhoweVege@users.noreply.github.com>
2025-09-30 22:38:05 +02:00
Manu
4ac89f6849 Add notify platform to Habitica (#150553) 2025-09-30 22:35:55 +02:00
Nojus
db3b070ed0 Add meteo_lt integration (#152948)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-30 22:17:36 +02:00
anishsane
6d940f476a Add support for Media player Mute/Unmute intents (#150508) 2025-09-30 14:37:19 -05:00
Erwin Douna
1ca701dda4 Portainer fix CONF_VERIFY_SSL (#153269)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-09-30 21:36:04 +02:00
Joost Lekkerkerker
291c44100c Add Eltako brand (#153276) 2025-09-30 21:29:58 +02:00
Joost Lekkerkerker
c8d676e06b Add Konnected brand (#153280) 2025-09-30 21:27:43 +02:00
Joost Lekkerkerker
4c1ae0eddc Add Level brand (#153279) 2025-09-30 21:21:21 +02:00
Norbert Rittel
39eadc814f Replace "Climate name" with "Climate program" in ecobee action (#153264) 2025-09-30 21:16:37 +02:00
Robert Resch
f7ecad61ba Bump aioecowitt to 2025.9.2 (#153273) 2025-09-30 20:58:34 +02:00
Norbert Rittel
fa4cb54549 Fix sentence-casing in two title strings of roomba (#153281) 2025-09-30 20:51:44 +02:00
Manu
2be33c5e0a Update quality scale of ntfy integration to platinum 🏆️ (#151785) 2025-09-30 20:36:18 +02:00
LG-ThinQ-Integration
904d7e5d5a Add air/water filter state in percent to LG ThinQ (#152150)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-30 20:26:47 +02:00
Pete Sage
dbc4a65d48 Fix Sonos Dialog Select type conversion part II (#152491)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-30 20:25:19 +02:00
Pete Sage
b93f4aabf1 Add tests for Sonos media metadata (#152622) 2025-09-30 20:24:57 +02:00
Joost Lekkerkerker
9eaa40c7a4 Require cloud for Aladdin Connect (#153278)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-30 19:57:24 +02:00
Lucas Mindêllo de Andrade
b308a882fb Add Roomba J9 compatibility to the roomba integration (#145913)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-09-30 19:10:22 +02:00
Erik Montnemery
7f63ba2087 Improve saved state of RestoreSensor when using freezegun (#152740) 2025-09-30 18:27:56 +02:00
Erik Montnemery
d7269cfcc6 Use pytest_unordered in additional service helper tests (#153255) 2025-09-30 18:26:32 +02:00
starkillerOG
2850a574f6 Add Reolink floodlight event entities (#152564) 2025-09-30 17:59:12 +02:00
Samuel Xiao
dcb8d4f702 Add support model [relay switch 2pm] for switchbot cloud (#148381) 2025-09-30 17:49:32 +02:00
Samuel Xiao
aeadc0c4b0 Add lock support to Switchbot Cloud (#148310) 2025-09-30 17:48:38 +02:00
Nathan Spencer
683c6b17be Add release url to Litter-Robot 4 update entity (#152504) 2025-09-30 17:47:27 +02:00
Samuel Xiao
69dd5c91b7 Switchbot Cloud: Fix Roller Shade not work issue (#152528) 2025-09-30 17:05:23 +02:00
HarvsG
5cf7dfca8f Pihole better logging of update errors (#152077) 2025-09-30 16:59:03 +02:00
Marc Mueller
62a49d4244 Update pandas to 2.3.3 (#153251) 2025-09-30 16:58:41 +02:00
falconindy
93ee6322f2 snoo: add button entity for calling start_snoo (#151052)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-09-30 16:57:58 +02:00
Artur Pragacz
914990b58a Add analytics platform to wled (#153258) 2025-09-30 10:39:32 -04:00
Joakim Sørensen
f78bb5adb6 Bump hass-nabucasa from 1.1.2 to 1.2.0 (#153250) 2025-09-30 15:29:04 +02:00
Erik Montnemery
905f5e7289 Add device class filter to entity services (#153247) 2025-09-30 14:28:04 +01:00
Erik Montnemery
ec503618c3 Handle errors in WS manifest/list (#153256) 2025-09-30 15:12:41 +02:00
Erik Montnemery
7a41cbc314 Skip unserializable flows in WS config_entries/flow/subscribe (#153259) 2025-09-30 15:12:19 +02:00
Erik Montnemery
c58ba734e7 Correct target filter in osoenergy services (#153244) 2025-09-30 14:06:14 +02:00
Erik Montnemery
68f63be62f Correct target filter in litterrobot services (#153243) 2025-09-30 14:05:46 +02:00
Erik Montnemery
2aa4ca1351 Correct homekit service definition (#153242) 2025-09-30 14:04:09 +02:00
Imeon-Energy
fbabb27787 Add forecast energy sensor to Imeon inverter integration (#152176)
Co-authored-by: TheBushBoy <theodavid@icloud.com>
2025-09-30 13:35:18 +02:00
Markus Jacobsen
0960d78eb5 Use initial received WebSocket state in Bang & Olufsen (#152432) 2025-09-30 13:34:43 +02:00
andreimoraru
474b40511f Bump yt-dlp to 2025.09.26 (#153252) 2025-09-30 13:19:06 +02:00
Jan-Philipp Benecke
18b80aced3 Record current quality scale of Electricity Maps (#149241) 2025-09-30 11:38:16 +02:00
Franck Nijhof
c75dca743a Bump version to 2025.10.0b5 2025-09-30 09:21:25 +00:00
Jan Bouwhuis
00d667ed51 Add missing translation strings for added sensor device classes pm4 and reactive energy (#153215) 2025-09-30 09:21:19 +00:00
c0ffeeca7
51e098e807 ZHA: rename radio to adapter (#153206) 2025-09-30 09:21:17 +00:00
RogerSelwyn
5e2b27699e Handle return result from ebusd being "empty" (#153199) 2025-09-30 09:21:16 +00:00
Erik Montnemery
be942c2888 Revert "Add mg/m³ as a valid UOM for sensor/number Carbon Monoxide device class" (#153196) 2025-09-30 09:21:14 +00:00
Erik Montnemery
584c1fbd97 Revert "Add comment on conversion factor for Carbon monoxide on dependency molecular weight" (#153195) 2025-09-30 09:21:13 +00:00
Joost Lekkerkerker
abc5c6e2b4 Mark Konnected as Legacy (#153193) 2025-09-30 09:21:11 +00:00
Martin Hjelmare
d9de964035 Add hardware Zigbee flow strategy (#153190) 2025-09-30 09:21:10 +00:00
Artur Pragacz
bb02158d1a Filter out empty integration type in extended analytics (#153188) 2025-09-30 09:21:08 +00:00
Simone Chemelli
be10f097c7 Bump aioamazondevices to 6.2.7 (#153185) 2025-09-30 09:21:07 +00:00
cdnninja
7084bca783 Correct vesync water tank lifted key (#153173) 2025-09-30 09:21:05 +00:00
Michael
cd6f3a0fe5 Add newly added cpu temperatures to diagnostics in FRITZ!Tools (#153168) 2025-09-30 09:21:03 +00:00
starkillerOG
af2888331d Bump reolink-aio to 0.16.0 (#153161) 2025-09-30 09:21:02 +00:00
Allen Porter
b92e5d7131 Add missing translations for Model Context Protocol integration (#153147) 2025-09-30 09:21:00 +00:00
Tom Matheussen
f7265c85d0 Fix entities not being created when adding subentries for Satel Integra (#153139) 2025-09-30 09:20:58 +00:00
G Johansson
8466dbf69f Fix event range in workday calendar (#153128) 2025-09-30 09:20:57 +00:00
Robert Resch
2dd0d69bcd Bump deebot-client to 15.0.0 (#153125) 2025-09-30 09:20:56 +00:00
Luca Graf
6783c4ad83 Ignore gateway device in ViCare integration (#153097) 2025-09-30 09:20:54 +00:00
G Johansson
07d7f4e18d Add timeout to dnsip (to handle stale connections) (#153086) 2025-09-30 09:20:53 +00:00
Simone Chemelli
54b1749986 Remove redundant code for Alexa Devices (#153083) 2025-09-30 09:20:51 +00:00
G Johansson
eaf264361f Fix can exclude optional holidays in workday (#153082) 2025-09-30 09:20:50 +00:00
Kyle Worrall
d8f6f17a4f Fix for Hue Integration motion aware areas (#153079)
Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-30 09:20:48 +00:00
Joakim Plate
9a969cea63 Ensure togrill detects disconnected devices (#153067) 2025-09-30 09:20:47 +00:00
Maciej Bieniek
ef16327b2b Add consumed energy sensor for Shelly pm1 and switch components (#153053) 2025-09-30 09:20:46 +00:00
Martin Hjelmare
a6a6261168 Improve hardware flow strings (#153034) 2025-09-30 09:20:44 +00:00
Erwin Douna
a01eb48db8 Portainer switch terminology to API token (#152958)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-09-30 09:20:43 +00:00
Christian McHugh
eb103a8d9a Fix: Set EPH climate heating as on only when boiler is actively heating (#152914) 2025-09-30 09:20:41 +00:00
Shay Levy
2b5f989855 Add Shelly EV charger sensors (#152722) 2025-09-30 09:20:40 +00:00
Thomas D
4e247a6ebe Prevent duplicate entities for Volvo integration (#151779) 2025-09-30 09:20:38 +00:00
dependabot[bot]
b964d362b7 Bump docker/login-action from 3.5.0 to 3.6.0 (#153239)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-30 11:14:17 +02:00
G Johansson
3914e41f3c Rename resolver to nameserver in dnsip (#153223) 2025-09-30 10:46:59 +02:00
Erik Montnemery
82bdfcb99b Correct target filter in ecovacs services (#153241) 2025-09-30 10:39:18 +03:00
abmantis
c3f45d594b Return future from runner 2025-09-28 12:59:16 +01:00
Abílio Costa
a95af1a40e Merge branch 'dev' into trigger_action_ux 2025-09-27 15:08:54 +01:00
Franck Nijhof
77f897a768 Bump version to 2025.10.0b4 2025-09-26 21:30:19 +00:00
Franck Nijhof
4f0a6ef9a1 Update Home Assistant base image to 2025.09.3 (#153064) 2025-09-26 21:30:12 +00:00
Franck Nijhof
66c6b0f5fc Bump version to 2025.10.0b3 2025-09-26 20:37:41 +00:00
Josef Zweck
dd01243391 Ensure token validity in lamarzocco (#153058) 2025-09-26 20:36:47 +00:00
SapuSeven
66c17e250a Add None-check for VeSync fan device.state.display_status (#153055) 2025-09-26 20:36:46 +00:00
DeerMaximum
723902e233 NINA Use better wording for filters (#153050) 2025-09-26 20:36:45 +00:00
Paul Bottein
59fdb9f3b5 Update frontend to 20250926.0 (#153049) 2025-09-26 20:36:44 +00:00
Martin Hjelmare
d83502514a Fix Thread flow abort on multiple flows (#153048) 2025-09-26 20:36:43 +00:00
Stefan Agner
08e81b2ba6 Update Home Assistant base image to 2025.09.2 (#153035) 2025-09-26 20:36:41 +00:00
Josef Zweck
1e808c965d Bump pylamarzocco to 2.1.1 (#153027) 2025-09-26 20:36:40 +00:00
Stefan Agner
563b58c9aa Bump to home-assistant/wheels@2025.09.1 (#153025) 2025-09-26 20:36:39 +00:00
Artur Pragacz
cf223880e8 Use satellite entity area in the assist pipeline (#153017) 2025-09-26 20:36:37 +00:00
J. Nick Koston
4058ca59ed Bump aioesphomeapi to 41.11.0 (#153014) 2025-09-26 20:36:36 +00:00
puddly
1386c01733 Allow ZHA discovery if discovery unique_id conflicts with config entry (#153009)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-26 20:36:35 +00:00
puddly
46504947f7 Bump ZHA to 0.0.73 (#153007) 2025-09-26 20:36:34 +00:00
Paulus Schoutsen
0a44682014 Push ESPHome discovery to ZJS addon (#153004) 2025-09-26 20:36:33 +00:00
Brandon Harvey
06a57473a9 Rename service to action in ESPHome (#152997) 2025-09-26 20:36:32 +00:00
Noah Husby
fbed66ef1f Bump aiorussound to 4.8.2 (#152988) 2025-09-26 20:36:30 +00:00
puddly
99a0380ec5 Ignore discovery for existing ZHA entries (#152984) 2025-09-26 20:36:29 +00:00
Simone Chemelli
68c51dc7aa Fix PIN failure if starting with 0 for Comelit SimpleHome (#152983) 2025-09-26 20:36:28 +00:00
lliwog
3d945b0fc5 Fix EZVIZ devices merging due to empty MAC addr (#152939) (#152981)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-26 20:36:27 +00:00
Erwin Douna
7b26a93d38 Portainer add ability to skip SSL verification (#152955) 2025-09-26 20:36:25 +00:00
Tom
1b2eab00be Add SSL options during config_flow for airOS (#150325)
Co-authored-by: Åke Strandberg <ake@strandberg.eu>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-09-26 20:36:24 +00:00
RogerSelwyn
750e849f09 Protect against last_comms being None (#149366)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-26 20:36:22 +00:00
Franck Nijhof
6aaddad56b Bump version to 2025.10.0b2 2025-09-25 18:19:29 +00:00
Paul Bottein
a5af974209 Update frontend to 20250925.1 (#152985) 2025-09-25 18:18:47 +00:00
Luke Lashley
09e45f6f54 Fix incorrect Roborock test (#152980) 2025-09-25 18:18:46 +00:00
Joost Lekkerkerker
d857d8850c Bump pySmartThings to 3.3.0 (#152977) 2025-09-25 18:18:45 +00:00
J. Nick Koston
ccc50f2412 Bump aioesphomeapi to 41.10.0 (#152975)
Co-authored-by: Michael Hansen <mike@rhasspy.org>
2025-09-25 18:18:43 +00:00
Maciej Bieniek
3905723900 Bump accuweather to version 4.2.2 (#152965) 2025-09-25 18:18:42 +00:00
Simone Chemelli
cee88473a2 Remove deprecated sensors and update remaning for Alexa Devices (#151230) 2025-09-25 18:18:40 +00:00
Daniel Potthast
cdf613d3f8 Update mvglive component (#146479)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-25 18:18:39 +00:00
abmantis
fa863649fa Merge branch 'trigger_action_ux' of github.com:home-assistant/core into trigger_action_ux 2025-09-25 15:41:20 +01:00
abmantis
b7c6e21707 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-09-25 15:13:55 +01:00
Franck Nijhof
156a0f1a3d Bump version to 2025.10.0b1 2025-09-25 09:37:33 +00:00
Paul Bottein
cc2a5b43dd Update frontend to 20250925.0 (#152945) 2025-09-25 09:37:03 +00:00
Erwin Douna
731064f7e9 Portainer fix unique entity (#152941)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-25 08:19:21 +00:00
Sab44
2f75661c20 Bump librehardwaremonitor-api to version 1.4.0 (#152938) 2025-09-25 08:19:20 +00:00
Paulus Schoutsen
be6f056f30 Prevent common control calling async methods from thread (#152931)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-09-25 08:19:18 +00:00
Franck Nijhof
79599e1284 Add block Spook < 4.0.0 as breaking Home Assistant (#152930) 2025-09-25 08:19:17 +00:00
Paulus Schoutsen
a255585ab6 Remove some more domains from common controls (#152927) 2025-09-25 08:19:15 +00:00
J. Nick Koston
e9bde225fe Bump aioesphomeapi to 41.9.4 (#152923) 2025-09-25 08:19:14 +00:00
Franck Nijhof
d9521ac2a0 Bump to home-assistant/wheels@2025.09.0 (#152920) 2025-09-25 08:19:13 +00:00
J. Nick Koston
d8b24ccccd Bump aioesphomeapi to 41.9.3 to fix segfault (#152912) 2025-09-25 08:19:12 +00:00
J. Nick Koston
b4417a76d5 Fix ESPHome reauth not being triggered on incorrect password (#152911) 2025-09-25 08:19:10 +00:00
Simone Chemelli
274f6eb54a Update IQS to platinum for Comelit SimpleHome (#152906) 2025-09-25 08:19:09 +00:00
Simone Chemelli
21a5aaf35c Update IQS to platinum for Alexa Devices (#152905) 2025-09-25 08:19:07 +00:00
Luke Lashley
05820a49d0 Fix logical error when user has no Roborock maps (#152752) 2025-09-25 08:19:06 +00:00
Abílio Costa
e7da1250ba Merge branch 'dev' into trigger_action_ux 2025-09-24 22:01:19 +01:00
Franck Nijhof
17b12d29af Bump version to 2025.10.0b0 2025-09-24 18:57:19 +00:00
abmantis
e71140e09b Fix typing 2025-09-24 17:11:45 +01:00
abmantis
53875f7188 Fix zwavejs device_trigger 2025-09-24 14:39:46 +01:00
abmantis
526541f666 Merge branch 'dev' of github.com:home-assistant/core into trigger_action_ux 2025-09-24 14:37:52 +01:00
abmantis
01d81f8980 Move attach_trigger out of Trigger class 2025-09-23 16:30:22 +01:00
abmantis
7d96a814f9 Simplify firing of trigger actions 2025-09-22 19:16:30 +01:00
368 changed files with 16622 additions and 5053 deletions

View File

@@ -190,7 +190,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -257,7 +257,7 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -332,14 +332,14 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -504,7 +504,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}

View File

@@ -203,6 +203,7 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.* homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.* homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
@@ -325,6 +326,7 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.lovelace.* homeassistant.components.lovelace.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.* homeassistant.components.madvr.*
homeassistant.components.manual.* homeassistant.components.manual.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*

6
CODEOWNERS generated
View File

@@ -492,6 +492,8 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST /tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes /homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes /tests/components/filter/ @dgomes
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky /homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP /homeassistant/components/firmata/ @DaAwesomeP
@@ -908,6 +910,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale /homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck /homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent /homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -953,6 +957,8 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo /homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View File

@@ -616,34 +616,44 @@ async def async_enable_logging(
), ),
) )
# Log errors to a file if we have write access to file or config dir logger = logging.getLogger()
logger.setLevel(logging.INFO if verbose else logging.WARNING)
if log_file is None: if log_file is None:
err_log_path = hass.config.path(ERROR_LOG_FILENAME) default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
else: else:
err_log_path = os.path.abspath(log_file) err_log_path = os.path.abspath(log_file)
err_path_exists = os.path.isfile(err_log_path) if err_log_path:
err_dir = os.path.dirname(err_log_path) err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that # Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not. # we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK) not err_path_exists and os.access(err_dir, os.W_OK)
): ):
err_handler = await hass.async_add_executor_job( err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days _create_log_file, err_log_path, log_rotate_days
) )
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger.addHandler(err_handler)
logger = logging.getLogger() # Save the log file location for access by other components.
logger.addHandler(err_handler) hass.data[DATA_LOGGING] = err_log_path
logger.setLevel(logging.INFO if verbose else logging.WARNING) else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
# Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass) async_activate_log_queue_handler(hass)

View File

@@ -0,0 +1,5 @@
{
"domain": "eltako",
"name": "Eltako",
"iot_standards": ["matter"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "konnected",
"name": "Konnected",
"integrations": ["konnected", "konnected_esphome"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "level",
"name": "Level",
"iot_standards": ["matter"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["airos==0.5.3"] "requirements": ["airos==0.5.4"]
} }

View File

@@ -114,6 +114,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
), ),
} }
PARALLEL_UPDATES = 0
@callback @callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:

View File

@@ -2,17 +2,14 @@
from airtouch4pyapi import AirTouch from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirtouchDataUpdateCoordinator from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry.""" """Set up AirTouch4 from a config entry."""
@@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs() info = airtouch.GetAcs()
if not info: if not info:
raise ConfigEntryNotReady raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View File

@@ -2,26 +2,34 @@
import logging import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data.""" """Class to manage fetching Airtouch data."""
def __init__(self, hass, airtouch): def __init__(
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
"""Initialize global Airtouch data updater.""" """Initialize global Airtouch data updater."""
self.airtouch = airtouch self.airtouch = airtouch
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )

View File

@@ -22,6 +22,17 @@ class OAuth2FlowHandler(
VERSION = CONFIG_FLOW_VERSION VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Check we have the cloud integration set up."""
if "cloud" not in self.hass.config.components:
return self.async_abort(
reason="cloud_not_enabled",
description_placeholders={"default_config": "default_config"},
)
return await super().async_step_user(user_input)
async def async_step_reauth( async def async_step_reauth(
self, user_input: Mapping[str, Any] self, user_input: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -24,7 +24,8 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account." "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"""Return detailed information about entities and devices.""" """Return detailed information about entities and devices."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
@@ -513,6 +513,8 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
integration_inputs: dict[str, tuple[list[str], list[str]]] = {} integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
integration_configs: dict[str, AnalyticsModifications] = {} integration_configs: dict[str, AnalyticsModifications] = {}
removed_devices: set[str] = set()
# Get device list # Get device list
for device_entry in dev_reg.devices.values(): for device_entry in dev_reg.devices.values():
if not device_entry.primary_config_entry: if not device_entry.primary_config_entry:
@@ -525,6 +527,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
if config_entry is None: if config_entry is None:
continue continue
if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
removed_devices.add(device_entry.id)
continue
integration_domain = config_entry.domain integration_domain = config_entry.domain
integration_input = integration_inputs.setdefault(integration_domain, ([], [])) integration_input = integration_inputs.setdefault(integration_domain, ([], []))
@@ -614,15 +620,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
device_config = integration_config.devices.get(device_id, device_config) device_config = integration_config.devices.get(device_id, device_config)
if device_config.remove: if device_config.remove:
removed_devices.add(device_id)
continue continue
device_entry = dev_reg.devices[device_id] device_entry = dev_reg.devices[device_id]
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) device_id_mapping[device_id] = (integration_domain, len(devices_info))
devices_info.append( devices_info.append(
{ {
"entities": [],
"entry_type": device_entry.entry_type, "entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None, "has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version, "hw_version": device_entry.hw_version,
@@ -631,6 +637,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
"model_id": device_entry.model_id, "model_id": device_entry.model_id,
"sw_version": device_entry.sw_version, "sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id, "via_device": device_entry.via_device_id,
"entities": [],
} }
) )
@@ -669,7 +676,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
entity_entry = ent_reg.entities[entity_id] entity_entry = ent_reg.entities[entity_id]
entity_state = hass.states.get(entity_entry.entity_id) entity_state = hass.states.get(entity_id)
entity_info = { entity_info = {
# LIMITATION: `assumed_state` can be overridden by users; # LIMITATION: `assumed_state` can be overridden by users;
@@ -690,15 +697,19 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
"unit_of_measurement": entity_entry.unit_of_measurement, "unit_of_measurement": entity_entry.unit_of_measurement,
} }
if ( if (device_id_ := entity_entry.device_id) is not None:
((device_id_ := entity_entry.device_id) is not None) if device_id_ in removed_devices:
and ((new_device_id := device_id_mapping.get(device_id_)) is not None) # The device was removed, so we remove the entity too
and (new_device_id[0] == integration_domain) continue
):
device_info = devices_info[new_device_id[1]] if (
device_info["entities"].append(entity_info) new_device_id := device_id_mapping.get(device_id_)
else: ) is not None and (new_device_id[0] == integration_domain):
entities_info.append(entity_info) device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
continue
entities_info.append(entity_info)
return { return {
"version": "home-assistant:1", "version": "home-assistant:1",

View File

@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, TypeVar from typing import Any
T = TypeVar("T", dict[str, Any], list[Any], None)
TRANSLATION_MAP = { TRANSLATION_MAP = {
"wan_rx": "sensor_rx_bytes", "wan_rx": "sensor_rx_bytes",
@@ -36,7 +34,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
def translate_to_legacy(raw: T) -> T: def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T:
"""Translate raw data to legacy format for dicts and lists.""" """Translate raw data to legacy format for dicts and lists."""
if raw is None: if raw is None:

View File

@@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
# Add the websocket and API client # Add the websocket and API client
entry.runtime_data = BangOlufsenData(websocket, client) entry.runtime_data = BangOlufsenData(websocket, client)
# Start WebSocket connection
await client.connect_notifications(remote_control=True, reconnect=True)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Start WebSocket connection once the platforms have been loaded.
# This ensures that the initial WebSocket notifications are dispatched to entities
await client.connect_notifications(remote_control=True, reconnect=True)
return True return True

View File

@@ -125,7 +125,8 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
new_entities=[ new_entities=[
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client) BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
] ],
update_before_add=True,
) )
# Register actions. # Register actions.
@@ -266,34 +267,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._software_status.software_version, self._software_status.software_version,
) )
# Get overall device state once. This is handled by WebSocket events the rest of the time.
product_state = await self._client.get_product_state()
# Get volume information.
if product_state.volume:
self._volume = product_state.volume
# Get all playback information.
# Ensure that the metadata is not None upon startup
if product_state.playback:
if product_state.playback.metadata:
self._playback_metadata = product_state.playback.metadata
self._remote_leader = product_state.playback.metadata.remote_leader
if product_state.playback.progress:
self._playback_progress = product_state.playback.progress
if product_state.playback.source:
self._source_change = product_state.playback.source
if product_state.playback.state:
self._playback_state = product_state.playback.state
# Set initial state
if self._playback_state.value:
self._state = self._playback_state.value
self._attr_media_position_updated_at = utcnow() self._attr_media_position_updated_at = utcnow()
# Get the highest resolution available of the given images.
self._media_image = get_highest_resolution_artwork(self._playback_metadata)
# If the device has been updated with new sources, then the API will fail here. # If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources() await self._async_update_sources()

View File

@@ -272,6 +272,13 @@ async def async_setup_entry(
observations: list[ConfigType] = [ observations: list[ConfigType] = [
dict(subentry.data) for subentry in config_entry.subentries.values() dict(subentry.data) for subentry in config_entry.subentries.values()
] ]
for observation in observations:
if observation[CONF_PLATFORM] == CONF_TEMPLATE:
observation[CONF_VALUE_TEMPLATE] = Template(
observation[CONF_VALUE_TEMPLATE], hass
)
prior: float = config[CONF_PRIOR] prior: float = config[CONF_PRIOR]
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)

View File

@@ -51,12 +51,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
@@ -118,12 +112,6 @@ ATTR_FILENAME: Final = "filename"
ATTR_MEDIA_PLAYER: Final = "media_player" ATTR_MEDIA_PLAYER: Final = "media_player"
ATTR_FORMAT: Final = "format" ATTR_FORMAT: Final = "format"
# These constants are deprecated as of Home Assistant 2024.10
# Please use the StreamType enum instead.
_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
class CameraEntityFeature(IntFlag): class CameraEntityFeature(IntFlag):
"""Supported features of the camera entity.""" """Supported features of the camera entity."""
@@ -1117,11 +1105,3 @@ async def async_handle_record_service(
duration=service_call.data[CONF_DURATION], duration=service_call.data[CONF_DURATION],
lookback=service_call.data[CONF_LOOKBACK], lookback=service_call.data[CONF_LOOKBACK],
) )
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -53,7 +53,6 @@ from .const import (
CONF_ACME_SERVER, CONF_ACME_SERVER,
CONF_ALEXA, CONF_ALEXA,
CONF_ALIASES, CONF_ALIASES,
CONF_CLOUDHOOK_SERVER,
CONF_COGNITO_CLIENT_ID, CONF_COGNITO_CLIENT_ID,
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_FILTER,
@@ -130,7 +129,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str, vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_CLOUDHOOK_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str,
vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str,
vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str,

View File

@@ -78,7 +78,6 @@ CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server" CONF_ACME_SERVER = "acme_server"
CONF_CLOUDHOOK_SERVER = "cloudhook_server"
CONF_RELAYER_SERVER = "relayer_server" CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.1.2"], "requirements": ["hass-nabucasa==1.2.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -0,0 +1,106 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
Stale docstring and test name: `test_form_home` and reusing result.
Extract `async_setup_entry` into own fixture.
Avoid importing `config_flow` in tests.
Test reauth with errors
config-flow:
status: todo
comment: |
The config flow misses data descriptions.
Remove URLs from data descriptions, they should be replaced with placeholders.
Make use of Electricity Maps zone keys in country code as dropdown.
Make use of location selector for coordinates.
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: todo
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
The integration does not provide any additional options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: |
Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})`
`test_sensor` could use `snapshot_platform`
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
This integration cannot be discovered, it is a connecting to a cloud service.
discovery:
status: exempt
comment: |
This integration cannot be discovered, it is a connecting to a cloud service.
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:
status: exempt
comment: |
The integration connects to a single service per configuration entry.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not raise any repairable issues.
stale-devices:
status: exempt
comment: |
This integration connect to a single device per configuration entry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
import logging
from typing import Any, NoReturn from typing import Any, NoReturn
from aiohttp import web from aiohttp import web
@@ -23,7 +24,12 @@ from homeassistant.helpers.data_entry_flow import (
FlowManagerResourceView, FlowManagerResourceView,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.json import json_fragment from homeassistant.helpers.json import (
JSON_DUMP,
find_paths_unserializable_data,
json_bytes,
json_fragment,
)
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
IntegrationNotFound, IntegrationNotFound,
@@ -31,6 +37,9 @@ from homeassistant.loader import (
async_get_integrations, async_get_integrations,
async_get_loaded_integration, async_get_loaded_integration,
) )
from homeassistant.util.json import format_unserializable_data
_LOGGER = logging.getLogger(__name__)
@callback @callback
@@ -402,18 +411,40 @@ def config_entries_flow_subscribe(
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
async_on_flow_init_remove async_on_flow_init_remove
) )
connection.send_message( try:
websocket_api.event_message( serialized_flows = [
msg["id"], json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
[ for flw in hass.config_entries.flow.async_progress()
{"type": None, "flow_id": flw["flow_id"], "flow": flw} if flw["context"]["source"]
for flw in hass.config_entries.flow.async_progress() not in (
if flw["context"]["source"] config_entries.SOURCE_RECONFIGURE,
not in ( config_entries.SOURCE_USER,
config_entries.SOURCE_RECONFIGURE, )
config_entries.SOURCE_USER, ]
except (ValueError, TypeError):
# If we can't serialize, we'll filter out unserializable flows
serialized_flows = []
for flw in hass.config_entries.flow.async_progress():
if flw["context"]["source"] in (
config_entries.SOURCE_RECONFIGURE,
config_entries.SOURCE_USER,
):
continue
try:
serialized_flows.append(
json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
) )
], except (ValueError, TypeError):
_LOGGER.error(
"Unable to serialize to JSON. Bad data found at %s",
format_unserializable_data(
find_paths_unserializable_data(flw, dump=JSON_DUMP)
),
)
continue
connection.send_message(
websocket_api.messages.construct_event_message(
msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]"))
) )
) )
connection.send_result(msg["id"]) connection.send_result(msg["id"])

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._attr_translation_key = "button" self._attr_translation_key = "button"
self._attr_translation_placeholders = {"key": str(key)} self._attr_translation_placeholders = {"key": str(key)}
def _sync(self, message: tuple) -> None: def sync_callback(self, message: tuple) -> None:
"""Update the binary sensor state.""" """Update the binary sensor state."""
if ( if (
message[0] == self._remote_control_property.element_uid message[0] == self._remote_control_property.element_uid

View File

@@ -48,7 +48,6 @@ class DevoloDeviceEntity(Entity):
) )
self.subscriber: Subscriber | None = None self.subscriber: Subscriber | None = None
self.sync_callback = self._sync
self._value: float self._value: float
@@ -69,7 +68,7 @@ class DevoloDeviceEntity(Entity):
self._device_instance.uid, self.subscriber self._device_instance.uid, self.subscriber
) )
def _sync(self, message: tuple) -> None: def sync_callback(self, message: tuple) -> None:
"""Update the state.""" """Update the state."""
if message[0] == self._attr_unique_id: if message[0] == self._attr_unique_id:
self._value = message[1] self._value = message[1]

View File

@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
""" """
return f"{self._attr_unique_id}_{self._sensor_type}" return f"{self._attr_unique_id}_{self._sensor_type}"
def _sync(self, message: tuple) -> None: def sync_callback(self, message: tuple) -> None:
"""Update the consumption sensor state.""" """Update the consumption sensor state."""
if message[0] == self._attr_unique_id: if message[0] == self._attr_unique_id:
self._value = getattr( self._value = getattr(

View File

@@ -13,8 +13,3 @@ class Subscriber:
"""Initiate the subscriber.""" """Initiate the subscriber."""
self.name = name self.name = name
self.callback = callback self.callback = callback
def update(self, message: str) -> None:
"""Trigger hass to update the device."""
_LOGGER.debug('%s got message "%s"', self.name, message)
self.callback(message)

View File

@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
"""Switch off the device.""" """Switch off the device."""
self._binary_switch_property.set(state=False) self._binary_switch_property.set(state=False)
def _sync(self, message: tuple) -> None: def sync_callback(self, message: tuple) -> None:
"""Update the binary switch state and consumption.""" """Update the binary switch state and consumption."""
if message[0].startswith("devolo.BinarySwitch"): if message[0].startswith("devolo.BinarySwitch"):
self._attr_is_on = self._device_instance.binary_switch_property[ self._attr_is_on = self._device_instance.binary_switch_property[

View File

@@ -56,16 +56,16 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME] hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME] name = entry.data[CONF_NAME]
resolver_ipv4 = entry.options[CONF_RESOLVER] nameserver_ipv4 = entry.options[CONF_RESOLVER]
resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6] nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT] port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6] port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = [] entities = []
if entry.data[CONF_IPV4]: if entry.data[CONF_IPV4]:
entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4)) entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
if entry.data[CONF_IPV6]: if entry.data[CONF_IPV6]:
entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6)) entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
async_add_entities(entities, update_before_add=True) async_add_entities(entities, update_before_add=True)
@@ -77,11 +77,13 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip" _attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
resolver: aiodns.DNSResolver
def __init__( def __init__(
self, self,
name: str, name: str,
hostname: str, hostname: str,
resolver: str, nameserver: str,
ipv6: bool, ipv6: bool,
port: int, port: int,
) -> None: ) -> None:
@@ -90,11 +92,11 @@ class WanIpSensor(SensorEntity):
self._attr_unique_id = f"{hostname}_{ipv6}" self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname self.hostname = hostname
self.port = port self.port = port
self._resolver = resolver self.nameserver = nameserver
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
self._retries = DEFAULT_RETRIES self._retries = DEFAULT_RETRIES
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"resolver": resolver, "resolver": nameserver,
"querytype": self.querytype, "querytype": self.querytype,
} }
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@@ -104,13 +106,13 @@ class WanIpSensor(SensorEntity):
model=aiodns.__version__, model=aiodns.__version__,
name=name, name=name,
) )
self.resolver: aiodns.DNSResolver
self.create_dns_resolver() self.create_dns_resolver()
def create_dns_resolver(self) -> None: def create_dns_resolver(self) -> None:
"""Create the DNS resolver.""" """Create the DNS resolver."""
self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port) self.resolver = aiodns.DNSResolver(
self.resolver.nameservers = [self._resolver] nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the current DNS IP address for hostname.""" """Get the current DNS IP address for hostname."""

View File

@@ -176,7 +176,7 @@
"description": "Sets the participating sensors for a climate program.", "description": "Sets the participating sensors for a climate program.",
"fields": { "fields": {
"preset_mode": { "preset_mode": {
"name": "Climate Name", "name": "Climate program",
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program." "description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
}, },
"device_ids": { "device_ids": {
@@ -188,7 +188,7 @@
}, },
"exceptions": { "exceptions": {
"invalid_preset": { "invalid_preset": {
"message": "Invalid climate name, available options are: {options}" "message": "Invalid climate program, available options are: {options}"
}, },
"invalid_sensor": { "invalid_sensor": {
"message": "Invalid sensor for thermostat, available options are: {options}" "message": "Invalid sensor for thermostat, available options are: {options}"

View File

@@ -2,3 +2,4 @@ raw_get_positions:
target: target:
entity: entity:
domain: vacuum domain: vacuum
integration: ecovacs

View File

@@ -6,5 +6,5 @@
"dependencies": ["webhook"], "dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt", "documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.1"] "requirements": ["aioecowitt==2025.9.2"]
} }

View File

@@ -0,0 +1,11 @@
"""Analytics platform."""
from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
from homeassistant.core import HomeAssistant
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
return AnalyticsModifications(remove=True)

View File

@@ -0,0 +1,27 @@
"""The Firefly III integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Set up Firefly III from a config entry."""
coordinator = FireflyDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,140 @@
"""Config flow for the Firefly III integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_API_KEY): str,
}
)
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect."""
try:
client = Firefly(
api_url=data[CONF_URL],
api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await client.get_about()
except FireflyAuthenticationError:
raise InvalidAuth from None
except FireflyConnectionError as err:
raise CannotConnect from err
except FireflyTimeoutError as err:
raise FireflyClientTimeout from err
return True
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Firefly III."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await _validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Firefly III API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for a new API key and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class FireflyClientTimeout(HomeAssistantError):
"""Error to indicate a timeout occurred."""

View File

@@ -0,0 +1,6 @@
"""Constants for the Firefly III integration."""
DOMAIN = "firefly_iii"
MANUFACTURER = "Firefly III"
NAME = "Firefly III"

View File

@@ -0,0 +1,137 @@
"""Data Update Coordinator for Firefly III integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from aiohttp import CookieJar
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
@dataclass
class FireflyCoordinatorData:
"""Data structure for Firefly III coordinator data."""
accounts: list[Account]
categories: list[Category]
category_details: list[Category]
budgets: list[Budget]
bills: list[Bill]
primary_currency: Currency
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
"""Coordinator to manage data updates for Firefly III integration."""
config_entry: FireflyConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.firefly = Firefly(
api_url=self.config_entry.data[CONF_URL],
api_key=self.config_entry.data[CONF_API_KEY],
session=async_create_clientsession(
self.hass,
self.config_entry.data[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.firefly.get_about()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now()
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
try:
accounts = await self.firefly.get_accounts()
categories = await self.firefly.get_categories()
category_details = [
await self.firefly.get_category(
category_id=int(category.id), start=start_date, end=end_date
)
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
return FireflyCoordinatorData(
accounts=accounts,
categories=categories,
category_details=category_details,
budgets=budgets,
bills=bills,
primary_currency=primary_currency,
)

View File

@@ -0,0 +1,40 @@
"""Base entity for Firefly III integration."""
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import FireflyDataUpdateCoordinator
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
"""Base class for Firefly III entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize a Firefly entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
)
},
)

View File

@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"account_type": {
"default": "mdi:bank",
"state": {
"expense": "mdi:cash-minus",
"revenue": "mdi:cash-plus",
"asset": "mdi:account-cash",
"liability": "mdi:hand-coin"
}
},
"category": {
"default": "mdi:label"
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"domain": "firefly_iii",
"name": "Firefly III",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.6"]
}

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
No explicit parallel updates are defined.
reauthentication-flow:
status: todo
comment: |
No reauthentication flow is defined. It will be done in a next iteration.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
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: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,142 @@
"""Sensor platform for Firefly III integration."""
from __future__ import annotations
from pyfirefly.models import Account, Category
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
from .entity import FireflyBaseEntity
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="account_type",
translation_key="account",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="category",
translation_key="category",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FireflyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firefly III sensor platform."""
coordinator = entry.runtime_data
entities: list[SensorEntity] = [
FireflyAccountEntity(
coordinator=coordinator,
entity_description=description,
account=account,
)
for account in coordinator.data.accounts
for description in ACCOUNT_SENSORS
]
entities.extend(
FireflyCategoryEntity(
coordinator=coordinator,
entity_description=description,
category=category,
)
for category in coordinator.data.category_details
for description in CATEGORY_SENSORS
)
async_add_entities(entities)
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III account."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
account: Account,
) -> None:
"""Initialize Firefly account entity."""
super().__init__(coordinator, entity_description)
self._account = account
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
self._attr_name = account.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
# Account type state doesn't go well with the icons.json. Need to fix it.
if account.attributes.type == "expense":
self._attr_icon = "mdi:cash-minus"
elif account.attributes.type == "asset":
self._attr_icon = "mdi:account-cash"
elif account.attributes.type == "revenue":
self._attr_icon = "mdi:cash-plus"
elif account.attributes.type == "liability":
self._attr_icon = "mdi:hand-coin"
else:
self._attr_icon = "mdi:bank"
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self._account.attributes.current_balance
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return extra state attributes for the account entity."""
return {
"account_role": self._account.attributes.account_role or "",
"account_type": self._account.attributes.type or "",
"current_balance": str(self._account.attributes.current_balance or ""),
}
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III category."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
category: Category,
) -> None:
"""Initialize Firefly category entity."""
super().__init__(coordinator, entity_description)
self._category = category
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
self._attr_name = category.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
spent_items = self._category.attributes.spent or []
earned_items = self._category.attributes.earned or []
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
if spent == 0 and earned == 0:
return None
return spent + earned

View File

@@ -0,0 +1,49 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "The API key for authenticating with Firefly",
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
},
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
}
}
}

View File

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

View File

@@ -620,6 +620,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
def create_generate_content_config(self) -> GenerateContentConfig: def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM.""" """Create the GenerateContentConfig for the LLM."""
options = self.subentry.data options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
thinking_config: ThinkingConfig | None = None
if model.startswith("models/gemini-2.5") and not model.endswith(
("tts", "image", "image-preview")
):
thinking_config = ThinkingConfig(include_thoughts=True)
return GenerateContentConfig( return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K), top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
@@ -652,7 +659,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
), ),
), ),
], ],
thinking_config=ThinkingConfig(include_thoughts=True), thinking_config=thinking_config,
) )

View File

@@ -1,14 +1,18 @@
"""The Growatt server PV inverter sensor integration.""" """The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping from collections.abc import Mapping
import logging
import growattServer import growattServer
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from .const import ( from .const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID, CONF_PLANT_ID,
DEFAULT_PLANT_ID, DEFAULT_PLANT_ID,
DEFAULT_URL, DEFAULT_URL,
@@ -19,36 +23,110 @@ from .const import (
from .coordinator import GrowattConfigEntry, GrowattCoordinator from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData from .models import GrowattRuntimeData
_LOGGER = logging.getLogger(__name__)
def get_device_list(
def get_device_list_classic(
api: growattServer.GrowattApi, config: Mapping[str, str] api: growattServer.GrowattApi, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]: ) -> tuple[list[dict[str, str]], str]:
"""Retrieve the device list for the selected plant.""" """Retrieve the device list for the selected plant."""
plant_id = config[CONF_PLANT_ID] plant_id = config[CONF_PLANT_ID]
# Log in to api and fetch first plant if no plant id is defined. # Log in to api and fetch first plant if no plant id is defined.
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) try:
if ( login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
not login_response["success"] # DEBUG: Log the actual response structure
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE except Exception as ex:
): _LOGGER.error("DEBUG - Login response: %s", login_response)
raise ConfigEntryError("Username, Password or URL may be incorrect!") raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
) from ex
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
_LOGGER.debug("Growatt login failed: %s", msg)
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
user_id = login_response["user"]["id"] user_id = login_response["user"]["id"]
if plant_id == DEFAULT_PLANT_ID: if plant_id == DEFAULT_PLANT_ID:
plant_info = api.plant_list(user_id) try:
plant_info = api.plant_list(user_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during plant list: {ex}"
) from ex
if not plant_info or "data" not in plant_info or not plant_info["data"]:
raise ConfigEntryError("No plants found for this account.")
plant_id = plant_info["data"][0]["plantId"] plant_id = plant_info["data"][0]["plantId"]
# Get a list of devices for specified plant to add sensors for. # Get a list of devices for specified plant to add sensors for.
devices = api.device_list(plant_id) try:
devices = api.device_list(plant_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
) from ex
return devices, plant_id return devices, plant_id
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Device list logic for Open API V1.
Note: Plant selection (including auto-selection if only one plant exists)
is handled in the config flow before this function is called. This function
only fetches devices for the already-selected plant_id.
"""
plant_id = config[CONF_PLANT_ID]
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
raise ConfigEntryError(
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
supported_devices = [
{
"deviceSn": device.get("device_sn", ""),
"deviceType": "min",
}
for device in devices
if device.get("type") == 7
]
for device in devices:
if device.get("type") != 7:
_LOGGER.warning(
"Device %s with type %s not supported in Open API V1, skipping",
device.get("device_sn", ""),
device.get("type"),
)
return supported_devices, plant_id
def get_device_list(
api, config: Mapping[str, str], api_version: str
) -> tuple[list[dict[str, str]], str]:
"""Dispatch to correct device list logic based on API version."""
if api_version == "v1":
return get_device_list_v1(api, config)
if api_version == "classic":
return get_device_list_classic(api, config)
raise ConfigEntryError(f"Unknown API version: {api_version}")
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool: ) -> bool:
"""Set up Growatt from a config entry.""" """Set up Growatt from a config entry."""
config = config_entry.data config = config_entry.data
username = config[CONF_USERNAME]
url = config.get(CONF_URL, DEFAULT_URL) url = config.get(CONF_URL, DEFAULT_URL)
# If the URL has been deprecated then change to the default instead # If the URL has been deprecated then change to the default instead
@@ -58,11 +136,24 @@ async def async_setup_entry(
new_data[CONF_URL] = url new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data) hass.config_entries.async_update_entry(config_entry, data=new_data)
# Initialise the library with the username & a random id each time it is started # Determine API version
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api.server_url = url api_version = "v1"
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
api_version = "classic"
username = config[CONF_USERNAME]
api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=username
)
api.server_url = url
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) devices, plant_id = await hass.async_add_executor_job(
get_device_list, api, config, api_version
)
# Create a coordinator for the total sensors # Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator( total_coordinator = GrowattCoordinator(
@@ -75,7 +166,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
) )
for device in devices for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
} }
# Perform the first refresh for the total coordinator # Perform the first refresh for the total coordinator

View File

@@ -1,22 +1,38 @@
"""Config flow for growatt server integration.""" """Config flow for growatt server integration."""
import logging
from typing import Any from typing import Any
import growattServer import growattServer
import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import callback from homeassistant.core import callback
from .const import ( from .const import (
ABORT_NO_PLANTS,
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID, CONF_PLANT_ID,
DEFAULT_URL, DEFAULT_URL,
DOMAIN, DOMAIN,
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE, LOGIN_INVALID_AUTH_CODE,
SERVER_URLS, SERVER_URLS,
) )
_LOGGER = logging.getLogger(__name__)
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class.""" """Config flow class."""
@@ -27,12 +43,98 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialise growatt server flow.""" """Initialise growatt server flow."""
self.user_id = None self.user_id: str | None = None
self.data: dict[str, Any] = {} self.data: dict[str, Any] = {}
self.auth_type: str | None = None
self.plants: list[dict[str, Any]] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
return self.async_show_menu(
step_id="user",
menu_options=["password_auth", "token_auth"],
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle username/password authentication."""
if user_input is None:
return self._async_show_password_form()
self.auth_type = AUTH_PASSWORD
# Traditional username/password authentication
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
try:
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error("Invalid response format during login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
self.user_id = login_response["user"]["id"]
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
async def async_step_token_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle API token authentication."""
if user_input is None:
return self._async_show_token_form()
self.auth_type = AUTH_API_TOKEN
# Using token authentication
token = user_input[CONF_TOKEN]
self.api = growattServer.OpenApiV1(token=token)
# Verify token by fetching plant list
try:
plant_response = await self.hass.async_add_executor_job(self.api.plant_list)
self.plants = plant_response.get("plants", [])
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt V1 API plant list: %s", ex)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except growattServer.GrowattV1ApiError as e:
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
getattr(e, "error_code", None),
)
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex
)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@callback @callback
def _async_show_user_form(self, errors=None): def _async_show_password_form(
"""Show the form to the user.""" self, errors: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the username/password form to the user."""
data_schema = vol.Schema( data_schema = vol.Schema(
{ {
vol.Required(CONF_USERNAME): str, vol.Required(CONF_USERNAME): str,
@@ -42,58 +144,87 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
) )
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors step_id="password_auth", data_schema=data_schema, errors=errors
) )
async def async_step_user( @callback
self, user_input: dict[str, Any] | None = None def _async_show_token_form(
self, errors: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the start of the config flow.""" """Show the API token form to the user."""
if not user_input: data_schema = vol.Schema(
return self._async_show_user_form() {
vol.Required(CONF_TOKEN): str,
# Initialise the library with the username & a random id each time it is started }
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
) )
if ( return self.async_show_form(
not login_response["success"] step_id="token_auth",
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE data_schema=data_schema,
): errors=errors,
return self._async_show_user_form({"base": "invalid_auth"}) )
self.user_id = login_response["user"]["id"]
self.data = user_input
return await self.async_step_plant()
async def async_step_plant( async def async_step_plant(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle adding a "plant" to Home Assistant.""" """Handle adding a "plant" to Home Assistant."""
plant_info = await self.hass.async_add_executor_job( if self.auth_type == AUTH_API_TOKEN:
self.api.plant_list, self.user_id # Using V1 API with token
) if not self.plants:
return self.async_abort(reason=ABORT_NO_PLANTS)
if not plant_info["data"]: # Create dictionary of plant_id -> name
return self.async_abort(reason="no_plants") plant_dict = {
str(plant["plant_id"]): plant.get("name", "Unknown Plant")
for plant in self.plants
}
plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]} if user_input is None and len(plant_dict) > 1:
data_schema = vol.Schema(
{vol.Required(CONF_PLANT_ID): vol.In(plant_dict)}
)
return self.async_show_form(step_id="plant", data_schema=data_schema)
if user_input is None and len(plant_info["data"]) > 1: if user_input is None:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) # Single plant => mark it as selected
user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]}
return self.async_show_form(step_id="plant", data_schema=data_schema) user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]]
if user_input is None: else:
# single plant => mark it as selected # Traditional API
user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]} try:
plant_info = await self.hass.async_add_executor_job(
self.api.plant_list, self.user_id
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API plant list: %s", ex)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
# Access plant_info["data"] - validate response structure
if not isinstance(plant_info, dict) or "data" not in plant_info:
_LOGGER.error(
"Invalid response format during plant list: missing 'data' key"
)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
plant_data = plant_info["data"]
if not plant_data:
return self.async_abort(reason=ABORT_NO_PLANTS)
plants = {plant["plantId"]: plant["plantName"] for plant in plant_data}
if user_input is None and len(plant_data) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
return self.async_show_form(step_id="plant", data_schema=data_schema)
if user_input is None:
# single plant => mark it as selected
user_input = {CONF_PLANT_ID: plant_data[0]["plantId"]}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
await self.async_set_unique_id(user_input[CONF_PLANT_ID]) await self.async_set_unique_id(user_input[CONF_PLANT_ID])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
self.data.update(user_input) self.data.update(user_input)

View File

@@ -4,6 +4,16 @@ from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id" CONF_PLANT_ID = "plant_id"
# API key support
CONF_API_KEY = "api_key"
# Auth types for config flow
AUTH_PASSWORD = "password"
AUTH_API_TOKEN = "api_token"
CONF_AUTH_TYPE = "auth_type"
DEFAULT_AUTH_TYPE = AUTH_PASSWORD
DEFAULT_PLANT_ID = "0" DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt" DEFAULT_NAME = "Growatt"
@@ -29,3 +39,10 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
LOGIN_INVALID_AUTH_CODE = "502" LOGIN_INVALID_AUTH_CODE = "502"
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"
# Config flow abort reasons
ABORT_NO_PLANTS = "no_plants"

View File

@@ -1,5 +1,7 @@
"""Coordinator module for managing Growatt data fetching.""" """Coordinator module for managing Growatt data fetching."""
from __future__ import annotations
import datetime import datetime
import json import json
import logging import logging
@@ -38,23 +40,31 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
plant_id: str, plant_id: str,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
self.username = config_entry.data[CONF_USERNAME] self.api_version = (
self.password = config_entry.data[CONF_PASSWORD] "v1" if config_entry.data.get("auth_type") == "api_token" else "classic"
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
) )
# Set server URL
self.api.server_url = self.url
self.device_id = device_id self.device_id = device_id
self.device_type = device_type self.device_type = device_type
self.plant_id = plant_id self.plant_id = plant_id
# Initialize previous_values to store historical data
self.previous_values: dict[str, Any] = {} self.previous_values: dict[str, Any] = {}
if self.api_version == "v1":
self.username = None
self.password = None
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
)
self.api.server_url = self.url
else:
raise ValueError(f"Unknown API version: {self.api_version}")
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
@@ -67,21 +77,54 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Update data via library synchronously.""" """Update data via library synchronously."""
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
# Login in to the Growatt server # login only required for classic API
self.api.login(self.username, self.password) if self.api_version == "classic":
self.api.login(self.username, self.password)
if self.device_type == "total": if self.device_type == "total":
total_info = self.api.plant_info(self.device_id) if self.api_version == "v1":
del total_info["deviceList"] # The V1 Plant APIs do not provide the same information as the classic plant_info() API
plant_money_text, currency = total_info["plantMoneyText"].split("/") # More specifically:
total_info["plantMoneyText"] = plant_money_text # 1. There is no monetary information to be found, so today and lifetime money is not available
total_info["currency"] = currency # 2. There is no nominal power, this is provided by inverter min_energy()
# This means, for the total coordinator we can only fetch and map the following:
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
total_info = self.api.plant_energy_overview(self.plant_id)
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
else:
# Classic API: use plant_info as before
total_info = self.api.plant_info(self.device_id)
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
_LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info)
self.data = total_info self.data = total_info
elif self.device_type == "inverter": elif self.device_type == "inverter":
self.data = self.api.inverter_detail(self.device_id) self.data = self.api.inverter_detail(self.device_id)
elif self.device_type == "min":
# Open API V1: min device
try:
min_details = self.api.min_detail(self.device_id)
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
_LOGGER.error(
"Error fetching min device data for %s: %s", self.device_id, err
)
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
_LOGGER.debug("min_info for device %s: %r", self.device_id, min_info)
elif self.device_type == "tlx": elif self.device_type == "tlx":
tlx_info = self.api.tlx_detail(self.device_id) tlx_info = self.api.tlx_detail(self.device_id)
self.data = tlx_info["data"] self.data = tlx_info["data"]
_LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info)
elif self.device_type == "storage": elif self.device_type == "storage":
storage_info_detail = self.api.storage_params(self.device_id) storage_info_detail = self.api.storage_params(self.device_id)
storage_energy_overview = self.api.storage_energy_overview( storage_energy_overview = self.api.storage_energy_overview(
@@ -145,7 +188,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self.data.get("currency") return self.data.get("currency")
def get_data( def get_data(
self, entity_description: "GrowattSensorEntityDescription" self, entity_description: GrowattSensorEntityDescription
) -> str | int | float | None: ) -> str | int | float | None:
"""Get the data.""" """Get the data."""
variable = entity_description.api_key variable = entity_description.api_key

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(
sensor_descriptions: list = [] sensor_descriptions: list = []
if device_coordinator.device_type == "inverter": if device_coordinator.device_type == "inverter":
sensor_descriptions = list(INVERTER_SENSOR_TYPES) sensor_descriptions = list(INVERTER_SENSOR_TYPES)
elif device_coordinator.device_type == "tlx": elif device_coordinator.device_type in ("tlx", "min"):
sensor_descriptions = list(TLX_SENSOR_TYPES) sensor_descriptions = list(TLX_SENSOR_TYPES)
elif device_coordinator.device_type == "storage": elif device_coordinator.device_type == "storage":
sensor_descriptions = list(STORAGE_SENSOR_TYPES) sensor_descriptions = list(STORAGE_SENSOR_TYPES)

View File

@@ -2,26 +2,42 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account" "no_plants": "No plants have been found on this account"
}, },
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "Authentication failed. Please check your credentials and try again.",
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again."
}, },
"step": { "step": {
"user": {
"title": "Choose authentication method",
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
"menu_options": {
"password_auth": "Username & Password",
"token_auth": "API Token (MIN/TLX only)"
}
},
"password_auth": {
"title": "Enter your Growatt login credentials",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]"
}
},
"token_auth": {
"title": "Enter your API token",
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"data": {
"token": "API Token"
}
},
"plant": { "plant": {
"data": { "data": {
"plant_id": "Plant" "plant_id": "Plant"
}, },
"title": "Select your plant" "title": "Select your plant"
},
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"url": "[%key:common::config_flow::data::url%]"
},
"title": "Enter your Growatt information"
} }
} }
}, },

View File

@@ -4,9 +4,14 @@ from uuid import UUID
from habiticalib import Habitica from habiticalib import Habitica
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@@ -27,6 +32,7 @@ PLATFORMS = [
Platform.BUTTON, Platform.BUTTON,
Platform.CALENDAR, Platform.CALENDAR,
Platform.IMAGE, Platform.IMAGE,
Platform.NOTIFY,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
Platform.TODO, Platform.TODO,
@@ -46,6 +52,7 @@ async def async_setup_entry(
"""Set up habitica from a config entry.""" """Set up habitica from a config entry."""
party_added_by_this_entry: UUID | None = None party_added_by_this_entry: UUID | None = None
device_reg = dr.async_get(hass) device_reg = dr.async_get(hass)
entity_registry = er.async_get(hass)
session = async_get_clientsession( session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
@@ -96,6 +103,15 @@ async def async_setup_entry(
device.id, remove_config_entry_id=config_entry.entry_id device.id, remove_config_entry_id=config_entry.entry_id
) )
notify_entities = [
entry.entity_id
for entry in entity_registry.entities.values()
if entry.domain == NOTIFY_DOMAIN
and entry.config_entry_id == config_entry.entry_id
]
for entity_id in notify_entities:
entity_registry.async_remove(entity_id)
hass.config_entries.async_schedule_reload(config_entry.entry_id) hass.config_entries.async_schedule_reload(config_entry.entry_id)
coordinator.async_add_listener(_party_update_listener) coordinator.async_add_listener(_party_update_listener)

View File

@@ -121,4 +121,4 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""If the binary sensor is on.""" """If the binary sensor is on."""
return self.coordinator.data.quest.active return self.coordinator.data.party.quest.active

View File

@@ -9,6 +9,7 @@ from datetime import timedelta
from io import BytesIO from io import BytesIO
import logging import logging
from typing import Any from typing import Any
from uuid import UUID
from aiohttp import ClientError from aiohttp import ClientError
from habiticalib import ( from habiticalib import (
@@ -48,6 +49,14 @@ class HabiticaData:
tasks: list[TaskData] tasks: list[TaskData]
@dataclass
class HabiticaPartyData:
"""Habitica party data."""
party: GroupData
members: dict[UUID, UserData]
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
@@ -192,11 +201,19 @@ class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]):
return png.getvalue() return png.getvalue()
class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]): class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
"""Habitica Party Coordinator.""" """Habitica Party Coordinator."""
_update_interval = timedelta(minutes=15) _update_interval = timedelta(minutes=15)
async def _update_data(self) -> GroupData: async def _update_data(self) -> HabiticaPartyData:
"""Fetch the latest party data.""" """Fetch the latest party data."""
return (await self.habitica.get_group()).data
return HabiticaPartyData(
party=(await self.habitica.get_group()).data,
members={
member.id: member
for member in (await self.habitica.get_group_members()).data
if member.id
},
)

View File

@@ -68,14 +68,14 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
super().__init__(coordinator) super().__init__(coordinator)
if TYPE_CHECKING: if TYPE_CHECKING:
assert config_entry.unique_id assert config_entry.unique_id
unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}" unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}"
self.entity_description = entity_description self.entity_description = entity_description
self._attr_unique_id = f"{unique_id}_{entity_description.key}" self._attr_unique_id = f"{unique_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=NAME, model=NAME,
name=coordinator.data.summary, name=coordinator.data.party.summary,
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
via_device=(DOMAIN, config_entry.unique_id), via_device=(DOMAIN, config_entry.unique_id),
) )

View File

@@ -174,6 +174,9 @@
}, },
"collected_items": { "collected_items": {
"default": "mdi:sack" "default": "mdi:sack"
},
"last_checkin": {
"default": "mdi:login-variant"
} }
}, },
"switch": { "switch": {
@@ -194,6 +197,11 @@
"quest_running": { "quest_running": {
"default": "mdi:script-text-play" "default": "mdi:script-text-play"
} }
},
"notify": {
"party_chat": {
"default": "mdi:forum"
}
} }
}, },
"services": { "services": {

View File

@@ -128,7 +128,7 @@ class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
"""Return URL of image.""" """Return URL of image."""
return ( return (
f"{ASSETS_URL}quest_{key}.png" f"{ASSETS_URL}quest_{key}.png"
if (key := self.coordinator.data.quest.key) if (key := self.coordinator.data.party.quest.key)
else None else None
) )

View File

@@ -0,0 +1,202 @@
"""Notify platform for the Habitica integration."""
from __future__ import annotations
from abc import abstractmethod
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
GroupData,
HabiticaException,
NotAuthorizedError,
NotFoundError,
TooManyRequestsError,
UserData,
)
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
NotifyEntity,
NotifyEntityDescription,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HABITICA_KEY
from .const import DOMAIN
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
PARALLEL_UPDATES = 10
class HabiticaNotify(StrEnum):
"""Habitica Notifier."""
PARTY_CHAT = "party_chat"
PRIVATE_MESSAGE = "private_message"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the notify entity platform."""
members_added: set[UUID] = set()
entity_registry = er.async_get(hass)
coordinator = config_entry.runtime_data
if party := coordinator.data.user.party.id:
party_coordinator = hass.data[HABITICA_KEY][party]
async_add_entities(
[HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)]
)
@callback
def add_entities() -> None:
nonlocal members_added
new_members = set(party_coordinator.data.members.keys()) - members_added
if TYPE_CHECKING:
assert coordinator.data.user.id
new_members.discard(coordinator.data.user.id)
if new_members:
async_add_entities(
HabiticaPrivateMessageNotifyEntity(
coordinator, party_coordinator.data.members[member]
)
for member in new_members
)
members_added |= new_members
delete_members = members_added - set(party_coordinator.data.members.keys())
for member in delete_members:
if entity_id := entity_registry.async_get_entity_id(
NOTIFY_DOMAIN,
DOMAIN,
f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
):
entity_registry.async_remove(entity_id)
members_added.discard(member)
party_coordinator.async_add_listener(add_entities)
add_entities()
class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
"""Habitica base notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
) -> None:
"""Initialize a Habitica entity."""
super().__init__(coordinator, self.entity_description)
@abstractmethod
async def _send_message(self, message: str) -> None:
"""Send a Habitica message."""
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message."""
try:
await self._send_message(message)
except NotAuthorizedError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_forbidden",
translation_placeholders={
**self.translation_placeholders,
"reason": e.error.message,
},
) from e
except NotFoundError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_not_found",
translation_placeholders={
**self.translation_placeholders,
"reason": e.error.message,
},
) from e
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": e.error.message},
) from e
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e)},
) from e
class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity):
"""Representation of a Habitica party chat notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
party: GroupData,
) -> None:
"""Initialize a Habitica entity."""
self._attr_translation_placeholders = {CONF_NAME: party.name}
self.entity_description = NotifyEntityDescription(
key=HabiticaNotify.PARTY_CHAT,
translation_key=HabiticaNotify.PARTY_CHAT,
)
self.party = party
super().__init__(coordinator)
async def _send_message(self, message: str) -> None:
"""Send a Habitica party chat message."""
await self.coordinator.habitica.send_group_message(
message=message,
group_id=self.party.id,
)
class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity):
"""Representation of a Habitica private message notify entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
member: UserData,
) -> None:
"""Initialize a Habitica entity."""
self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""}
self.entity_description = NotifyEntityDescription(
key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
translation_key=HabiticaNotify.PRIVATE_MESSAGE,
)
self.member = member
super().__init__(coordinator)
async def _send_message(self, message: str) -> None:
"""Send a Habitica private message."""
if TYPE_CHECKING:
assert self.member.id
await self.coordinator.habitica.send_private_message(
message=message,
to_user_id=self.member.id,
)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum from enum import StrEnum
import logging import logging
from typing import Any from typing import Any
@@ -53,7 +54,7 @@ PARALLEL_UPDATES = 1
class HabiticaSensorEntityDescription(SensorEntityDescription): class HabiticaSensorEntityDescription(SensorEntityDescription):
"""Habitica Sensor Description.""" """Habitica Sensor Description."""
value_fn: Callable[[UserData, ContentData], StateType] value_fn: Callable[[UserData, ContentData], StateType | datetime]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = ( attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None None
) )
@@ -114,6 +115,7 @@ class HabiticaSensorEntity(StrEnum):
COLLECTED_ITEMS = "collected_items" COLLECTED_ITEMS = "collected_items"
BOSS_RAGE = "boss_rage" BOSS_RAGE = "boss_rage"
BOSS_RAGE_LIMIT = "boss_rage_limit" BOSS_RAGE_LIMIT = "boss_rage_limit"
LAST_CHECKIN = "last_checkin"
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
@@ -284,6 +286,16 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS, translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
value_fn=pending_quest_items, value_fn=pending_quest_items,
), ),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.LAST_CHECKIN,
translation_key=HabiticaSensorEntity.LAST_CHECKIN,
value_fn=(
lambda user, _: dt_util.as_local(last)
if (last := user.auth.timestamps.loggedin)
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
) )
@@ -399,7 +411,7 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
entity_description: HabiticaSensorEntityDescription entity_description: HabiticaSensorEntityDescription
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
return self.entity_description.value_fn( return self.entity_description.value_fn(
@@ -442,10 +454,12 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
entity_description: HabiticaPartySensorEntityDescription entity_description: HabiticaPartySensorEntityDescription
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
return self.entity_description.value_fn(self.coordinator.data, self.content) return self.entity_description.value_fn(
self.coordinator.data.party, self.content
)
@property @property
def entity_picture(self) -> str | None: def entity_picture(self) -> str | None:
@@ -453,7 +467,9 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
pic = self.entity_description.entity_picture pic = self.entity_description.entity_picture
entity_picture = ( entity_picture = (
pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data) pic
if isinstance(pic, str) or pic is None
else pic(self.coordinator.data.party)
) )
return ( return (
@@ -468,5 +484,5 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return entity specific state attributes.""" """Return entity specific state attributes."""
if func := self.entity_description.attributes_fn: if func := self.entity_description.attributes_fn:
return func(self.coordinator.data, self.content) return func(self.coordinator.data.party, self.content)
return None return None

View File

@@ -264,6 +264,14 @@
"name": "[%key:component::habitica::common::quest_name%]" "name": "[%key:component::habitica::common::quest_name%]"
} }
}, },
"notify": {
"party_chat": {
"name": "Party chat"
},
"private_message": {
"name": "Private message: {name}"
}
},
"sensor": { "sensor": {
"display_name": { "display_name": {
"name": "Display name", "name": "Display name",
@@ -282,6 +290,9 @@
} }
} }
}, },
"last_checkin": {
"name": "Last check-in"
},
"health": { "health": {
"name": "Health", "name": "Health",
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
@@ -572,6 +583,12 @@
}, },
"frequency_not_monthly": { "frequency_not_monthly": {
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies." "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
},
"send_message_forbidden": {
"message": "You are not allowed to send messages to {name}. ({reason})"
},
"send_message_not_found": {
"message": "Unable to send message, {name} not found. ({reason})"
} }
}, },
"issues": { "issues": {

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio", "documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["aiohasupervisor==0.3.3b0"], "requirements": ["aiohasupervisor==0.3.3"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -10,6 +10,7 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
@@ -67,6 +68,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext context: ConfigFlowContext
# `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we
# try them in this order is that on older adapters `baudrate` entered the ESP32-S3
# bootloader instead of the MG24 bootloader.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -156,7 +157,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity.""" """Connect ZBT-2 firmware update entity."""
bootloader_reset_type = None bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
def __init__( def __init__(
self, self,

View File

@@ -39,6 +39,7 @@ from .util import (
FirmwareInfo, FirmwareInfo,
OwningAddon, OwningAddon,
OwningIntegration, OwningIntegration,
ResetTarget,
async_flash_silabs_firmware, async_flash_silabs_firmware,
get_otbr_addon_manager, get_otbr_addon_manager,
guess_firmware_info, guess_firmware_info,
@@ -79,6 +80,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware.""" """Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
_picked_firmware_type: PickedFirmwareType _picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -155,34 +158,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(), description_placeholders=self._get_translation_placeholders(),
) )
async def _probe_firmware_info(
self,
probe_methods: tuple[ApplicationType, ...] = (
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
probe_methods=probe_methods,
)
return (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type
in (
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
)
async def _install_firmware_step( async def _install_firmware_step(
self, self,
fw_update_url: str, fw_update_url: str,
@@ -236,12 +211,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
expected_installed_firmware_type: ApplicationType, expected_installed_firmware_type: ApplicationType,
) -> None: ) -> None:
"""Install firmware.""" """Install firmware."""
if not await self._probe_firmware_info():
raise AbortFlow(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
assert self._device is not None assert self._device is not None
# Keep track of the firmware we're working with, for error messages # Keep track of the firmware we're working with, for error messages
@@ -250,6 +219,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is # Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type # installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality. # isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
firmware_install_required = self._probed_firmware_info is None or ( firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type self._probed_firmware_info.firmware_type != expected_installed_firmware_type
) )
@@ -301,12 +272,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Otherwise, fail # Otherwise, fail
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(reason="firmware_download_failed") from err
await async_flash_silabs_firmware( self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass, hass=self.hass,
device=self._device, device=self._device,
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type, expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_type=None, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
progress_callback=lambda offset, total: self.async_update_progress( progress_callback=lambda offset, total: self.async_update_progress(
offset / total offset / total
), ),
@@ -314,15 +285,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
async def _configure_and_start_otbr_addon(self) -> None: async def _configure_and_start_otbr_addon(self) -> None:
"""Configure and start the OTBR addon.""" """Configure and start the OTBR addon."""
# Before we start the addon, confirm that the correct firmware is running
# and populate `self._probed_firmware_info` with the correct information
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
raise AbortFlow(
"unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass) otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager) addon_info = await self._async_get_addon_info(otbr_manager)
@@ -444,12 +406,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
return await self.async_step_install_zigbee_firmware() return await self.async_step_install_zigbee_firmware()
return await self.async_step_prepare_thread_installation() return await self.async_step_install_thread_firmware()
async def async_step_prepare_thread_installation( async def async_step_finish_thread_installation(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Prepare for Thread installation by stopping the OTBR addon if needed.""" """Finish Thread installation by starting the OTBR addon."""
if not is_hassio(self.hass): if not is_hassio(self.hass):
return self.async_abort( return self.async_abort(
reason="not_hassio_thread", reason="not_hassio_thread",
@@ -459,22 +421,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
otbr_manager = get_otbr_addon_manager(self.hass) otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager) addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.RUNNING:
# Stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
return await self.async_step_install_thread_firmware()
async def async_step_finish_thread_installation(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finish Thread installation by starting the OTBR addon."""
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED: if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon() return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.RUNNING:
await otbr_manager.async_stop_addon()
return await self.async_step_start_otbr_addon() return await self.async_step_start_otbr_addon()
async def async_step_pick_firmware_zigbee( async def async_step_pick_firmware_zigbee(
@@ -511,12 +463,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._device is not None assert self._device is not None
assert self._hardware_name is not None assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if self._zigbee_integration == ZigbeeIntegration.OTHER: if self._zigbee_integration == ZigbeeIntegration.OTHER:
return self._async_flow_finished() return self._async_flow_finished()

View File

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

View File

@@ -22,7 +22,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback from .helpers import async_register_firmware_info_callback
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_flash_silabs_firmware,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions # Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription entity_description: FirmwareUpdateEntityDescription
bootloader_reset_type: str | None = None bootloader_reset_methods: list[ResetTarget] = []
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -268,7 +273,7 @@ class BaseFirmwareUpdateEntity(
device=self._current_device, device=self._current_device,
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type, expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_type=self.bootloader_reset_type, bootloader_reset_methods=self.bootloader_reset_methods,
progress_callback=self._update_progress, progress_callback=self._update_progress,
) )
finally: finally:

View File

@@ -4,13 +4,16 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import logging import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.const import (
ApplicationType as FlasherApplicationType,
ResetTarget as FlasherResetTarget,
)
from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher from universal_silabs_flasher.flasher import Flasher
@@ -42,9 +45,9 @@ class ApplicationType(StrEnum):
"""Application type running on a device.""" """Application type running on a device."""
GECKO_BOOTLOADER = "bootloader" GECKO_BOOTLOADER = "bootloader"
CPC = "cpc"
EZSP = "ezsp" EZSP = "ezsp"
SPINEL = "spinel" SPINEL = "spinel"
CPC = "cpc"
ROUTER = "router" ROUTER = "router"
@classmethod @classmethod
@@ -59,6 +62,18 @@ class ApplicationType(StrEnum):
return FlasherApplicationType(self.value) return FlasherApplicationType(self.value)
class ResetTarget(StrEnum):
"""Methods to reset a device into bootloader mode."""
RTS_DTR = "rts_dtr"
BAUDRATE = "baudrate"
YELLOW = "yellow"
def as_flasher_reset_target(self) -> FlasherResetTarget:
"""Convert the reset target enum into one compatible with USF."""
return FlasherResetTarget(self.value)
@singleton(OTBR_ADDON_MANAGER_DATA) @singleton(OTBR_ADDON_MANAGER_DATA)
@callback @callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
@@ -342,7 +357,7 @@ async def async_flash_silabs_firmware(
device: str, device: str,
fw_data: bytes, fw_data: bytes,
expected_installed_firmware_type: ApplicationType, expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None, bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo: ) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.""" """Flash firmware to the SiLabs device."""
@@ -359,7 +374,9 @@ async def async_flash_silabs_firmware(
ApplicationType.SPINEL.as_flasher_application_type(), ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(), ApplicationType.CPC.as_flasher_application_type(),
), ),
bootloader_reset=bootloader_reset_type, bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
) )
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:

View File

@@ -168,7 +168,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity.""" """SkyConnect firmware update entity."""
bootloader_reset_type = None # The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []
def __init__( def __init__(
self, self,

View File

@@ -27,6 +27,8 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
probe_silabs_firmware_info,
) )
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_HARDWARE, SOURCE_HARDWARE,
@@ -82,6 +84,8 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods.""" """Mixin for Home Assistant Yellow firmware methods."""
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -141,8 +145,10 @@ class HomeAssistantYellowConfigFlow(
self, data: dict[str, Any] | None = None self, data: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
await self._probe_firmware_info() self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running # Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if ( if (

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -173,7 +174,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity.""" """Yellow firmware update entity."""
bootloader_reset_type = "yellow" # Triggers a GPIO reset bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
def __init__( def __init__(
self, self,

View File

@@ -2,8 +2,12 @@
reload: reload:
reset_accessory: reset_accessory:
target: fields:
entity: {} entity_id:
required: true
selector:
entity:
multiple: true
unpair: unpair:
fields: fields:

View File

@@ -76,7 +76,13 @@
}, },
"reset_accessory": { "reset_accessory": {
"name": "Reset accessory", "name": "Reset accessory",
"description": "Resets a HomeKit accessory." "description": "Resets a HomeKit accessory.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Entity to reset."
}
}
}, },
"unpair": { "unpair": {
"name": "Unpair an accessory or bridge", "name": "Unpair an accessory or bridge",

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller", "documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"], "loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.18"], "requirements": ["aiohomekit==3.2.19"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
} }

View File

@@ -169,6 +169,12 @@
}, },
"energy_battery_consumed": { "energy_battery_consumed": {
"default": "mdi:battery-arrow-down-outline" "default": "mdi:battery-arrow-down-outline"
},
"forecast_cons_remaining_today": {
"default": "mdi:chart-line"
},
"forecast_prod_remaining_today": {
"default": "mdi:chart-line"
} }
}, },
"select": { "select": {

View File

@@ -417,6 +417,21 @@ SENSOR_DESCRIPTIONS = (
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2, suggested_display_precision=2,
), ),
# Forecast
SensorEntityDescription(
key="forecast_cons_remaining_today",
translation_key="forecast_cons_remaining_today",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
),
SensorEntityDescription(
key="forecast_prod_remaining_today",
translation_key="forecast_prod_remaining_today",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
),
) )

View File

@@ -213,6 +213,12 @@
}, },
"energy_battery_consumed": { "energy_battery_consumed": {
"name": "Today battery-consumed energy" "name": "Today battery-consumed energy"
},
"forecast_cons_remaining_today": {
"name": "Forecast remaining energy consumption for today"
},
"forecast_prod_remaining_today": {
"name": "Forecast remaining energy production for today"
} }
}, },
"select": { "select": {

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] type INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]

View File

@@ -0,0 +1 @@
"""Virtual integration: Konnected ESPHome."""

View File

@@ -0,0 +1,6 @@
{
"domain": "konnected_esphome",
"name": "Konnected",
"integration_type": "virtual",
"supported_by": "esphome"
}

View File

@@ -282,9 +282,24 @@
"filter_lifetime": { "filter_lifetime": {
"default": "mdi:air-filter" "default": "mdi:air-filter"
}, },
"top_filter_remain_percent": {
"default": "mdi:air-filter"
},
"used_time": { "used_time": {
"default": "mdi:air-filter" "default": "mdi:air-filter"
}, },
"water_filter_state": {
"default": "mdi:air-filter"
},
"water_filter_1_remain_percent": {
"default": "mdi:air-filter"
},
"water_filter_2_remain_percent": {
"default": "mdi:air-filter"
},
"water_filter_3_remain_percent": {
"default": "mdi:air-filter"
},
"current_job_mode": { "current_job_mode": {
"default": "mdi:dots-circle" "default": "mdi:dots-circle"
}, },

View File

@@ -110,6 +110,11 @@ FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.FILTER_LIFETIME, translation_key=ThinQProperty.FILTER_LIFETIME,
), ),
ThinQProperty.TOP_FILTER_REMAIN_PERCENT: SensorEntityDescription(
key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT,
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT,
),
} }
HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription( ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription(
@@ -221,6 +226,11 @@ REFRIGERATION_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
translation_key=ThinQProperty.FRESH_AIR_FILTER, translation_key=ThinQProperty.FRESH_AIR_FILTER,
), ),
ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT: SensorEntityDescription(
key=ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT,
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.FRESH_AIR_FILTER,
),
} }
RUN_STATE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { RUN_STATE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.CURRENT_STATE: SensorEntityDescription( ThinQProperty.CURRENT_STATE: SensorEntityDescription(
@@ -303,6 +313,25 @@ WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
native_unit_of_measurement=UnitOfTime.MONTHS, native_unit_of_measurement=UnitOfTime.MONTHS,
translation_key=ThinQProperty.USED_TIME, translation_key=ThinQProperty.USED_TIME,
), ),
ThinQProperty.WATER_FILTER_STATE: SensorEntityDescription(
key=ThinQProperty.WATER_FILTER_STATE,
translation_key=ThinQProperty.WATER_FILTER_STATE,
),
ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT: SensorEntityDescription(
key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT,
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT,
),
ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT: SensorEntityDescription(
key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT,
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT,
),
ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT: SensorEntityDescription(
key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT,
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT,
),
} }
WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.WATER_TYPE: SensorEntityDescription( ThinQProperty.WATER_TYPE: SensorEntityDescription(
@@ -437,6 +466,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT],
FILTER_INFO_SENSOR_DESC[ThinQProperty.TOP_FILTER_REMAIN_PERCENT],
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE],
TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
@@ -513,7 +543,12 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
), ),
DeviceType.REFRIGERATOR: ( DeviceType.REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.USED_TIME], WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.USED_TIME],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_STATE],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT],
), ),
DeviceType.ROBOT_CLEANER: ( DeviceType.ROBOT_CLEANER: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],

View File

@@ -241,7 +241,9 @@
"timer_is_complete": "Timer has been completed", "timer_is_complete": "Timer has been completed",
"washing_is_complete": "Washing is completed", "washing_is_complete": "Washing is completed",
"water_is_full": "Water is full", "water_is_full": "Water is full",
"water_leak_has_occurred": "The dishwasher has detected a water leak" "water_leak_has_occurred": "The dishwasher has detected a water leak",
"filter_reset_complete": "The filter lifetime has been reset",
"water_filter_reset_complete": "The water filter lifetime has been reset"
} }
} }
} }
@@ -608,9 +610,24 @@
"filter_lifetime": { "filter_lifetime": {
"name": "Filter remaining" "name": "Filter remaining"
}, },
"top_filter_remain_percent": {
"name": "Upper filter remaining"
},
"used_time": { "used_time": {
"name": "Water filter used" "name": "Water filter used"
}, },
"water_filter_state": {
"name": "Water filter"
},
"water_filter_1_remain_percent": {
"name": "[%key:component::lg_thinq::entity::sensor::water_filter_state::name%]"
},
"water_filter_2_remain_percent": {
"name": "Water filter stage 2"
},
"water_filter_3_remain_percent": {
"name": "Water filter stage 3"
},
"current_job_mode": { "current_job_mode": {
"name": "Operating mode", "name": "Operating mode",
"state": { "state": {

View File

@@ -3,6 +3,7 @@
set_sleep_mode: set_sleep_mode:
target: target:
entity: entity:
domain: vacuum
integration: litterrobot integration: litterrobot
fields: fields:
enabled: enabled:

View File

@@ -26,6 +26,7 @@ FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription(
key="firmware", key="firmware",
device_class=UpdateDeviceClass.FIRMWARE, device_class=UpdateDeviceClass.FIRMWARE,
) )
RELEASE_URL = "https://www.litter-robot.com/releases.html"
async def async_setup_entry( async def async_setup_entry(
@@ -48,6 +49,7 @@ async def async_setup_entry(
class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity): class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
"""A class that describes robot update entities.""" """A class that describes robot update entities."""
_attr_release_url = RELEASE_URL
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
) )

View File

@@ -13,28 +13,16 @@ from propcache.api import cached_property
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401 from homeassistant.const import (
_DEPRECATED_STATE_JAMMED,
_DEPRECATED_STATE_LOCKED,
_DEPRECATED_STATE_LOCKING,
_DEPRECATED_STATE_UNLOCKED,
_DEPRECATED_STATE_UNLOCKING,
ATTR_CODE, ATTR_CODE,
ATTR_CODE_FORMAT, ATTR_CODE_FORMAT,
SERVICE_LOCK, SERVICE_LOCK,
SERVICE_OPEN, SERVICE_OPEN,
SERVICE_UNLOCK, SERVICE_UNLOCK,
STATE_OPEN,
STATE_OPENING,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.helpers.typing import ConfigType, StateType
@@ -317,11 +305,3 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return return
self._lock_option_default_code = "" self._lock_option_default_code = ""
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -0,0 +1,64 @@
"""The Lunatone integration."""
from typing import Final
from lunatone_rest_api_client import Auth, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import (
LunatoneConfigEntry,
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(auth_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
if info_api.serial_number is None:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(info_api.serial_number))},
name=info_api.name,
manufacturer="Lunatone",
sw_version=info_api.version,
hw_version=info_api.data.device.pcb,
configuration_url=entry.data[CONF_URL],
serial_number=str(info_api.serial_number),
model_id=(
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
),
)
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,83 @@
"""Config flow for Lunatone."""
from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
{vol.Required(CONF_URL, default="http://"): cv.string},
)
def compose_title(name: str | None, serial_number: int) -> str:
"""Compose a title string from a given name and serial number."""
return f"{name or 'DALI Gateway'} {serial_number}"
class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""Lunatone config flow."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
errors["base"] = "invalid_url"
except aiohttp.ClientConnectionError:
errors["base"] = "cannot_connect"
else:
if info_api.data is None or info_api.serial_number is None:
errors["base"] = "missing_device_info"
else:
await self.async_set_unique_id(str(info_api.serial_number))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=data,
title=compose_title(info_api.name, info_api.serial_number),
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=compose_title(info_api.name, info_api.serial_number),
data={CONF_URL: url},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)

View File

@@ -0,0 +1,5 @@
"""Constants for the Lunatone integration."""
from typing import Final
DOMAIN: Final = "lunatone"

View File

@@ -0,0 +1,101 @@
"""Coordinator for handling data fetching and updates."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import Device, Devices, Info
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
@dataclass
class LunatoneData:
"""Data for Lunatone integration."""
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
"""Data update coordinator for Lunatone info."""
config_entry: LunatoneConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
)
self.info_api = info_api
async def _async_update_data(self) -> InfoData:
"""Update info data."""
try:
await self.info_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve info data from Lunatone REST API"
) from ex
if self.info_api.data is None:
raise UpdateFailed("Did not receive info data from Lunatone REST API")
return self.info_api.data
class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
"""Data update coordinator for Lunatone devices."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
devices_api: Devices,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-devices",
always_update=False,
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
)
self.devices_api = devices_api
async def _async_update_data(self) -> dict[int, Device]:
"""Update devices data."""
try:
await self.devices_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve devices data from Lunatone REST API"
) from ex
if self.devices_api.data is None:
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
return {device.id: device for device in self.devices_api.devices}

View File

@@ -0,0 +1,103 @@
"""Platform for Lunatone light integration."""
from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lunatone Light platform."""
coordinator_info = config_entry.runtime_data.coordinator_info
coordinator_devices = config_entry.runtime_data.coordinator_devices
async_add_entities(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
)
for device_id in coordinator_devices.data
]
)
class LunatoneLight(
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
):
"""Representation of a Lunatone light."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
coordinator: LunatoneDevicesDataUpdateCoordinator,
device_id: int,
interface_serial_number: int,
) -> None:
"""Initialize a LunatoneLight."""
super().__init__(coordinator=coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data.get(self._device_id)
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
assert self.unique_id
name = self._device.name if self._device is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=name,
via_device=(DOMAIN, str(self._interface_serial_number)),
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._device is not None
@property
def is_on(self) -> bool:
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data.get(self._device_id)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -0,0 +1,11 @@
{
"domain": "lunatone",
"name": "Lunatone",
"codeowners": ["@MoonDevLT"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lunatone",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.4.8"]
}

View File

@@ -0,0 +1,82 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules:
status: exempt
comment: |
This integration has only one platform which uses a coordinator.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: no actions
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: Discovery not yet supported
discovery:
status: todo
comment: Discovery not yet supported
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
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: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"user": {
"description": "Connect to the API of your Lunatone DALI IoT Gateway.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
}
},
"reconfigure": {
"description": "Update the URL.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}

View File

@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = datetime.timedelta(minutes=30) UPDATE_INTERVAL = datetime.timedelta(minutes=30)
TIMEOUT = 10 TIMEOUT = 10
TokenManager = Callable[[], Awaitable[str]] type TokenManager = Callable[[], Awaitable[str]]
@asynccontextmanager @asynccontextmanager

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated", "iot_class": "calculated",
"loggers": ["yt_dlp"], "loggers": ["yt_dlp"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.09.23"], "requirements": ["yt-dlp[default]==2025.09.26"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -55,12 +55,6 @@ from homeassistant.const import ( # noqa: F401
from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.network import get_url from homeassistant.helpers.network import get_url
@@ -75,26 +69,6 @@ from .browse_media import ( # noqa: F401
async_process_play_media_url, async_process_play_media_url,
) )
from .const import ( # noqa: F401 from .const import ( # noqa: F401
_DEPRECATED_MEDIA_CLASS_DIRECTORY,
_DEPRECATED_SUPPORT_BROWSE_MEDIA,
_DEPRECATED_SUPPORT_CLEAR_PLAYLIST,
_DEPRECATED_SUPPORT_GROUPING,
_DEPRECATED_SUPPORT_NEXT_TRACK,
_DEPRECATED_SUPPORT_PAUSE,
_DEPRECATED_SUPPORT_PLAY,
_DEPRECATED_SUPPORT_PLAY_MEDIA,
_DEPRECATED_SUPPORT_PREVIOUS_TRACK,
_DEPRECATED_SUPPORT_REPEAT_SET,
_DEPRECATED_SUPPORT_SEEK,
_DEPRECATED_SUPPORT_SELECT_SOUND_MODE,
_DEPRECATED_SUPPORT_SELECT_SOURCE,
_DEPRECATED_SUPPORT_SHUFFLE_SET,
_DEPRECATED_SUPPORT_STOP,
_DEPRECATED_SUPPORT_TURN_OFF,
_DEPRECATED_SUPPORT_TURN_ON,
_DEPRECATED_SUPPORT_VOLUME_MUTE,
_DEPRECATED_SUPPORT_VOLUME_SET,
_DEPRECATED_SUPPORT_VOLUME_STEP,
ATTR_APP_ID, ATTR_APP_ID,
ATTR_APP_NAME, ATTR_APP_NAME,
ATTR_ENTITY_PICTURE_LOCAL, ATTR_ENTITY_PICTURE_LOCAL,
@@ -188,17 +162,6 @@ class MediaPlayerDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass)) DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
# DEVICE_CLASS* below are deprecated as of 2021.12
# use the MediaPlayerDeviceClass enum instead.
_DEPRECATED_DEVICE_CLASS_TV = DeprecatedConstantEnum(
MediaPlayerDeviceClass.TV, "2025.10"
)
_DEPRECATED_DEVICE_CLASS_SPEAKER = DeprecatedConstantEnum(
MediaPlayerDeviceClass.SPEAKER, "2025.10"
)
_DEPRECATED_DEVICE_CLASS_RECEIVER = DeprecatedConstantEnum(
MediaPlayerDeviceClass.RECEIVER, "2025.10"
)
DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass] DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass]
@@ -1196,6 +1159,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
media_content_id: str | None = None, media_content_id: str | None = None,
media_filter_classes: list[MediaClass] | None = None, media_filter_classes: list[MediaClass] | None = None,
) -> SearchMedia: ) -> SearchMedia:
"""Search for media."""
return await self.async_search_media( return await self.async_search_media(
query=SearchMediaQuery( query=SearchMediaQuery(
search_query=search_query, search_query=search_query,
@@ -1510,13 +1474,3 @@ async def async_fetch_image(
logger.warning("Error retrieving proxied image from %s", url) logger.warning("Error retrieving proxied image from %s", url)
return content, content_type return content, content_type
# As we import deprecated constants from the const module, we need to add these two functions
# otherwise this module will be logged for using deprecated constants and not the custom component
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

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