Compare commits

..

155 Commits

Author SHA1 Message Date
jbouwh
47753e8aed Add raw data option instead of assuming that the user can reset reset the image encoding 2025-09-22 09:16:04 +00:00
jbouwh
4b1b28e429 Wrap multiline lambda in parenthesis 2025-09-20 11:29:34 +00:00
jbouwh
e8fb264824 Add mqtt image subentry support 2025-09-20 11:29:34 +00:00
Jan Bouwhuis
054a5d751a Organize order MQTT subentry (test) globals and translation strings (#152576) 2025-09-20 13:24:30 +02:00
Andrea Turri
a43ba4f966 Miele add new program phase mapping (#152647) 2025-09-20 11:33:12 +02:00
J. Nick Koston
1a5cae125f Handle unparsable responses during HomeKit Controller initial polling (#152636) 2025-09-19 22:51:21 -05:00
Aidan Timson
71c274cb91 Add power usage sensor to System Bridge (#152625) 2025-09-19 21:16:30 +02:00
Retha Runolfsson
d4902361e6 Bump pySwitchbot to 0.71.0 (#152597) 2025-09-19 22:04:42 +03:00
Norbert Rittel
f63eee3889 Fix typo and sentence-casing in honeywell exception string (#152619) 2025-09-19 17:07:46 +02:00
Aidan Timson
21bfe610d1 Update systembridgeconnector to 5.1.0 (#152623) 2025-09-19 17:07:15 +02:00
J. Nick Koston
21c174e895 Bump aioesphomeapi to 41.4.0 (#152618)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-09-19 08:20:00 -06:00
Simon Roberts
ec148e0459 Add PM4 (particulates < 4μm) sensor and number device classes (#112867)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-09-19 08:12:09 -06:00
Maciej Bieniek
286763b998 Fix KeyError for Shelly Duo Bulb Gen3 (#152612) 2025-09-19 07:22:48 -06:00
Shay Levy
5f88122a2b Fix Shelly Wall Display virtual button platform (#152582) 2025-09-19 11:44:08 +03:00
Åke Strandberg
31968d16ab Refactor miele program phase codes part 2(3) (#144180) 2025-09-19 09:54:24 +02:00
epenet
c125554817 Rename modbus base entities (#152595) 2025-09-19 09:17:31 +02:00
Abílio Costa
10f2955d34 Update Whirlpool quality scale to silver (#152505) 2025-09-19 08:30:10 +02:00
Paulus Schoutsen
55712b784c Bump aioesphomeapi to 41.3.0 (#152588) 2025-09-18 22:49:03 -04:00
Paulus Schoutsen
fe3a929556 Fix reolink test (#152587) 2025-09-18 21:03:13 -04:00
J. Nick Koston
534801e80d Migrate yale and august integrations to use new lock API (#152579) 2025-09-18 17:20:38 -05:00
J. Nick Koston
8aeda5a0c0 Bump aioesphomeapi to 41.2.0 (#152578) 2025-09-19 01:03:59 +03:00
Ville Skyttä
eb1cbbc75c Upgrade upcloud-api to 2.8.0 (#152577) 2025-09-19 01:03:31 +03:00
Przemko92
fa8a4d7098 Add Compit integration (#132164)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-09-18 21:53:49 +01:00
Andre Lengwenus
2623ebac4d Bump pypck to 0.8.12 (#152573) 2025-09-18 23:34:37 +03:00
Michael Hansen
1746c51ce4 Fix error with pipeline device removal due to multiple selects (#152560) 2025-09-18 14:34:03 -05:00
Joakim Sørensen
8b984a2105 Remove ludeeus as a codeowner for analytics (#152558) 2025-09-18 22:08:22 +03:00
Allen Porter
ebee370a56 Bump python roborock to 2.44.1 (#152557) 2025-09-18 21:51:16 +03:00
starkillerOG
dabd096587 Add color temperature support to Reolink light entity (#152546) 2025-09-18 21:48:18 +03:00
Simone Chemelli
21399818af Remove stale devices for Comelit SimpleHome (#151519) 2025-09-18 19:43:38 +01:00
G Johansson
4354214fbf Bump holidays to 0.81 (#152569) 2025-09-18 20:35:21 +02:00
Erik Montnemery
5bd39804f1 Remove EntityComponent.async_register_legacy_entity_service (#152539) 2025-09-18 20:34:25 +02:00
Norbert Rittel
6d3ad3ab9c Replace "iCloud account" with "Apple Account" (#152561) 2025-09-18 18:39:54 +02:00
Petar Petrov
4c212bdcd4 Enable thread migration for ZBT integration (#152550) 2025-09-18 18:33:06 +03:00
Petar Petrov
b91b39580f Add migrate options to ZBT protocol picker (#152532) 2025-09-18 11:13:58 -04:00
Jan Bouwhuis
472d70b6c9 Add comment on conversion factor for Carbon monoxide on dependency molecular weight (#152535) 2025-09-18 15:36:12 +02:00
tronikos
017a84a859 Bump opower to 0.15.5 (#152531) 2025-09-18 14:29:27 +03:00
starkillerOG
d184540967 Bump reolink-aio to 0.15.1 (#152533) 2025-09-18 14:28:16 +03:00
Erik Montnemery
1740984b3b Improve comments in SelectedEntities (#152540) 2025-09-18 14:12:33 +03:00
droans
4db8592c61 Add support for overriding entity_picture to universal (#149387) 2025-09-18 11:59:29 +02:00
Stefan Agner
27e630c107 Make systemmonitor tests timezone independent (#152537) 2025-09-18 12:58:09 +03:00
Åke Strandberg
ea8833342d Bump dependency pymiele to v0.5.5 and subsequent code changes (#152534) 2025-09-18 10:33:55 +02:00
epenet
87be2ba823 Use compat UOM in _is_valid_suggested_unit (#152350)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-18 08:38:15 +02:00
Artur Pragacz
51c35eb631 Move default conversation agent to manager (#152479) 2025-09-18 08:22:56 +02:00
Samuel Xiao
24a86d042f Bumb switchbot api to v2.8.0 (#152506) 2025-09-18 07:46:51 +02:00
Manu
cd6f653123 Bump aiontfy to v0.6.0 (#152520) 2025-09-18 07:45:22 +02:00
J. Nick Koston
fd05ddca28 Bump yalexs to 9.2.0 (#152527) 2025-09-18 07:43:54 +02:00
Artur Pragacz
a1f2eb44ae Move trigger-specific fields into options in new-style triggers (#151314) 2025-09-18 07:35:39 +02:00
Samuel Xiao
c4ddc03dbc Update codeowner for switchbot cloud Integration (#152526) 2025-09-17 22:54:22 -05:00
J. Nick Koston
9db5aafb71 Bump yalexs to 9.1.0 (#152457) 2025-09-17 22:11:35 -05:00
Ivan Lopez Hernandez
64cdcfb613 Bump google-genai to 1.38.0 (#152523) 2025-09-17 22:14:04 -04:00
Paulus Schoutsen
c761ce699c Tweak usage prediction common control algorithm (#152490) 2025-09-17 19:04:25 -04:00
Martin Hjelmare
40ebce4ae8 Improve Home Assistant Hardware flow (#152451)
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2025-09-17 18:23:38 -04:00
puddly
29914d6722 Bump ZHA to 0.0.71 (#152511) 2025-09-17 18:23:08 -04:00
Jan Bouwhuis
5eef6edded Add mg/m³ as a valid UOM for sensor/number Carbon Monoxide device class (#152456) 2025-09-17 22:04:23 +01:00
epenet
db729273a5 Add pymodbus to PACKAGE_CHECK_VERSION_RANGE (#152494) 2025-09-17 17:22:45 +02:00
Abílio Costa
946d75d651 Merge similar Whirlpool init tests (#152497) 2025-09-17 17:18:54 +02:00
Abílio Costa
093f779edb Remove target humidity methods from Whirlpool climate (#152498) 2025-09-17 17:18:20 +02:00
Paulus Schoutsen
87658e77a7 Clean up stale comment in AI Task test (#152492) 2025-09-17 17:17:20 +02:00
dontinelli
38f65cda98 Bump solarlog_cli to 0.6.0 (#152500) 2025-09-17 16:02:51 +01:00
Jan Čermák
797c6ddedd Fix APT cache restore failures in CI (#152481)
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
2025-09-17 16:52:09 +02:00
NANI
fe8a53407a Add Victron Remote Monitoring integration (#143687)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-09-17 16:38:04 +02:00
Abílio Costa
ae5f57fd99 Add unique_id to Whirlpool config entry mock (#152496) 2025-09-17 14:38:53 +01:00
Abílio Costa
a93c3cc23c Make Whirlpool log when entity goes unavailable (#152064) 2025-09-17 15:00:23 +02:00
Pete Sage
804b42e1fb Fix Sonos set_volume float precision issue (#152493) 2025-09-17 14:39:28 +02:00
karwosts
a4f15e4840 Add debug logging to derivative (#152489) 2025-09-17 14:19:48 +02:00
Pete Sage
2471177c84 Set Sonos quality scale to bronze (#152487) 2025-09-17 14:16:08 +02:00
Yuxin Wang
a494d3ec69 Sort the resources for deterministic sensor addition order in APCUPSD (#152467) 2025-09-17 10:41:19 +02:00
Paulus Schoutsen
b10a9721a7 Add async_get_image helper to Image integration (#152465) 2025-09-17 10:35:55 +02:00
Paulus Schoutsen
04c0bb20d6 AI Task to store generated images in media dir (#152463) 2025-09-17 10:30:15 +02:00
Paulus Schoutsen
1598c4ebe8 Bump aioesphomeapi to 41.1.0 (#152461)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-09-16 20:18:54 -04:00
karwosts
d67ec7593a Add diagnostics to history_stats (#152460) 2025-09-17 00:02:35 +01:00
Franck Nijhof
4a4c124181 Refactor template engine: Extract collection & data structure functions into CollectionExtension (#152446) 2025-09-16 18:48:50 -04:00
Franck Nijhof
c34af4be86 Add active built-in and custom integrations to Cloud support package (#152452) 2025-09-16 18:47:00 -04:00
GSzabados
823071b722 Add LDS01 support (#151820)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-09-16 21:33:47 +02:00
Daniel Jansen
462fa77ba1 Improve waze_travel_time tests (#146495)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-16 21:24:51 +02:00
Yevhenii Vaskivskyi
24fc8b9297 Fix bug with the hardcoded configuration_url (asuswrt) (#151858) 2025-09-16 21:18:29 +02:00
Paulus Schoutsen
2596ab2940 OpenAI to use provided mimetype when available (#152407) 2025-09-16 22:11:46 +03:00
Robert Resch
23fa84e20e Verify that Ecovacs integration is setup without any errors in the tests (#152447) 2025-09-16 20:55:44 +02:00
Shay Levy
7f13141297 Bump aioshelly 13.10.0 (#152442) 2025-09-16 21:25:09 +03:00
karwosts
770f41d079 Diagnostics for derivative sensor (#152445) 2025-09-16 14:24:05 -04:00
Shay Levy
df16e85359 Fix typo in update_not_available key in Shelly strings (#152444) 2025-09-16 14:23:10 -04:00
Nathan Spencer
3c6db923a3 Deprecate Litter-Robot 4 night light mode switch (#152249) 2025-09-16 20:18:26 +02:00
Thomas D
450c47f932 Use new method to get the access token in the Volvo integration (#151625)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-16 20:17:43 +02:00
Norbert Rittel
048f64eccf Improve two unsupported_xxx issue descriptions in hassio (#152387)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2025-09-16 19:52:12 +02:00
Maciej Bieniek
c4c523e8b7 Open a repair issue if Shelly Wall Display firmware is older than 2.3.0 (#152399) 2025-09-16 19:48:47 +02:00
Matthias Alphart
87e30e0907 Fix KNX UI schema missing DPT (#152430) 2025-09-16 19:39:39 +02:00
Alexandre CUER
74660da2d2 Bump pyemoncms to 0.1.3 (#152436) 2025-09-16 18:32:13 +02:00
Maciej Bieniek
6b8c180509 Bump imgw_pib to version 1.5.6 (#152435) 2025-09-16 18:30:22 +02:00
Alessandro Manighetti
eb4a873c43 Add m/min of speed sensors (#146441) 2025-09-16 18:02:22 +02:00
G Johansson
6aafa666d6 Add calendar to Workday (#150596) 2025-09-16 17:29:04 +02:00
Mike Degatano
9ee9bb368d Move Supervisor created persistent notifications into repairs (#152066) 2025-09-16 11:24:48 -04:00
Tom Matheussen
6e4258c8a9 Add Satel Integra config flow (#138946)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-09-16 17:24:15 +02:00
Paulus Schoutsen
d65e704823 Add usage_prediction integration (#151206)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-09-16 10:33:46 -04:00
Retha Runolfsson
aadaf87c16 Add switchbot relayswitch 2PM (#146140) 2025-09-16 15:59:13 +02:00
Timothy
e70b147c0c Add missing content type to backup http endpoint (#152433) 2025-09-16 09:45:21 -04:00
yufeng
031b12752f Add sensors for Tuya energy storage systems (xnyjcn) (#149237)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-09-16 15:34:21 +02:00
Samuel Xiao
df0cfd69a9 Add Climate Panel support to Switchbot Cloud (#152427) 2025-09-16 14:14:09 +02:00
marc7s
b2c53f2d78 Add geocaching cache sensors (#145453) 2025-09-16 14:13:54 +02:00
Rafael López Diez
3649e949b1 Add support for sending chat actions in Telegram bot integration (#151378) 2025-09-16 14:06:15 +02:00
Rafael López Diez
de7e2303a7 Add support for multi-tap action in Lutron Caseta integration (#150551) 2025-09-16 13:32:10 +02:00
onsmam
892f3f267b Added rain_start and lightningstrike event to publish on the event bus (#146652)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-16 13:31:43 +02:00
Marc Mueller
0254285285 Fix warning in template extensions tests (#152425) 2025-09-16 13:30:36 +02:00
Chris Oldfield
44a95242dc Add downloading and seeding counts to Deluge (#150623) 2025-09-16 13:06:14 +02:00
Marc Mueller
f9b1c52d65 Fix warning in prowl tests (#152424) 2025-09-16 12:42:37 +02:00
Josef Zweck
aa8d78622c Add La Marzocco specific client headers (#152419) 2025-09-16 13:15:57 +03:00
Franck Nijhof
ca6289a576 Refactor template engine: Extract string functions into StringExtension (#152420) 2025-09-16 13:15:43 +03:00
Erik Montnemery
0f372f4b47 Improve condition schema validation (#144793) 2025-09-16 10:44:26 +02:00
Franck Nijhof
4bba167ab3 Refactor template engine: Extract regex functions into RegexExtension (#152417) 2025-09-16 10:38:01 +02:00
Jan-Philipp Benecke
962c0c443d Improve setup completion message of Improv BLE (#152412) 2025-09-16 10:37:47 +02:00
Duco Sebel
c6b4cac28a Remember HomeWizard uptime sensor value as timestamp to prevent it spamming the state (#150680)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 10:29:37 +02:00
dependabot[bot]
3c7e3a5e30 Bump home-assistant/builder from 2025.03.0 to 2025.09.0 (#152413)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 10:28:49 +02:00
Maciej Bieniek
fa698956c3 Fix the illuminance level entity name in Shelly integration (#152400)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-09-16 10:16:43 +02:00
Klaas Schoute
32f136b12f Update P1 Monitor integration to use settings method during config flow (#152391) 2025-09-16 10:11:29 +02:00
Josef Zweck
e1f617df25 Bump pylamarzocco to 2.1.0 (#152364) 2025-09-16 10:08:08 +02:00
Tomeroeni
84f1b8a5cc Bump aiounifi version to 87 (#152395) 2025-09-16 10:04:06 +02:00
kylehakala
e9cedf4852 Bump aioridwell to 2025.09.0 (#152405) 2025-09-16 10:01:50 +02:00
kingy444
9c72b40ab4 Bump HunterDouglas_Powerview dependency to aiopvapi 3.2.1 (#152409) 2025-09-16 09:58:41 +02:00
Marcus Gustavsson
65f655e5f5 Change Prowl to use the prowlpy library and add tests for the Prowl component (#149034)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-16 09:23:08 +02:00
Erik Montnemery
af28573894 Refactor zwave js event trigger (#144885) 2025-09-16 07:49:09 +02:00
Jan-Philipp Benecke
c5fc1de3df Update url in success message of Improv BLE to use markdown (#152388) 2025-09-15 19:50:19 +01:00
karwosts
1df1144eb9 Add 'stations near me' to radio browser (#150907) 2025-09-15 14:47:16 -04:00
Artur Pragacz
d51c0e3752 Revert "Add Matter service actions for vacuum area (#151467)" (#152386) 2025-09-15 20:14:09 +02:00
Ernst Klamer
f5157878c2 Bthome encryption fix (#152384) 2025-09-15 20:46:43 +03:00
Lennard Beers
fb723571b6 Bump eq3btsmart to 2.3.0 (#152383) 2025-09-15 18:36:12 +01:00
puddly
dbf80c3ce3 Bump universal-silabs-flasher to 0.0.32 (#152381) 2025-09-15 19:10:52 +02:00
Joost Lekkerkerker
e0a774b598 Add sensor test to Nederlandse Spoorwegen (#152375) 2025-09-15 18:03:14 +01:00
Manu
168afc5f0e Bump pyrate-limiter to v3.9.0 (#152370) 2025-09-15 18:54:26 +02:00
Paulus Schoutsen
af23670854 Add quality-scale-verifier Claude agent (#152333) 2025-09-15 17:40:15 +01:00
Jan Bouwhuis
935ce421df Remove unused const in MQTT JSON Light component (#152377) 2025-09-15 11:36:19 -05:00
Klaas Schoute
c60ad8179d Bump p1-monitor to v3.2.0 (#152378) 2025-09-15 18:12:04 +02:00
Nc Hodges
14ad3364e3 Add Re-Configure workflow to the Elk M1 Integration (#146368)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-09-15 11:02:00 -05:00
J. Nick Koston
e229f36648 Clarify contributor responsibility when using AI-generated code (#152379) 2025-09-15 17:59:06 +02:00
Norbert Rittel
f4f99e015c Clarify "discovery_requires_supervisor" message in zwave_js (#152345) 2025-09-15 17:14:41 +02:00
Joost Lekkerkerker
5dc509cba0 Add typing to Nederlandse Spoorwegen (#152367) 2025-09-15 16:19:39 +02:00
Shay Levy
75597ac98d Add Shelly removal condition for virtual components (#152312) 2025-09-15 16:15:15 +03:00
Heindrich Paul
b503f792b5 Add config flow to NS (#151567)
Signed-off-by: Heindrich Paul <heindrich.paul@gmail.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-09-15 15:13:43 +02:00
Ludovic BOUÉ
410c3df6dd Add Matter service actions for vacuum area (#151467)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-09-15 13:52:26 +02:00
virtualbitzz
f1bf28df18 Add Matter climate running state heat fan and cool fan (#151535)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-09-15 13:28:40 +02:00
Patrick
99fb64af9b Add new USB drives to Synology DSM without reloading integration (#146829)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-15 13:12:57 +02:00
Shay Levy
c0af0159e3 Use Entity Description in Shelly light platform (#137118) 2025-09-15 14:10:25 +03:00
Jan Bouwhuis
71749da3a3 Rename MQTT tag and device_automation setup helpers (#152344) 2025-09-15 11:52:24 +02:00
Norbert Rittel
b01be94034 Update "Find my iPhone" to "Find My" in icloud (#152354) 2025-09-15 11:52:08 +02:00
Jan Gutowski
47ec8b7f12 Bump nibe to 2.18.0 (#152353) 2025-09-15 11:41:44 +02:00
dependabot[bot]
93ec9e448e Bump sigstore/cosign-installer from 3.9.2 to 3.10.0 (#152343)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 11:20:21 +02:00
Lennard Beers
90bc41dd02 Bump eq3btsmart to 2.2.0 (#152334) 2025-09-15 11:16:39 +02:00
epenet
410d869f3d Improve type hints in zha tests (#152347) 2025-09-15 11:16:10 +02:00
Imeon-Energy
d75d9f2589 Bump imeon_inverter_api to 0.4.0 (#152351)
Co-authored-by: TheBushBoy <theodavid@icloud.com>
2025-09-15 11:14:31 +02:00
Christopher Fenner
afbb832a57 Improve config flow for openweathermap integration (#152319) 2025-09-15 11:13:39 +02:00
epenet
bdc881c87a Handle missing argument in hass_enforce_type_hints (#152342) 2025-09-15 09:45:27 +02:00
J. Nick Koston
22ea269ed8 Bump aioesphomeapi to 41.0.0 (#152332) 2025-09-14 23:22:10 -04:00
tronikos
10fecbaf4d Mark Opower as bronze (#148103)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-09-14 23:21:05 -04:00
Franck Nijhof
cbdc1dc5b6 Refactor template engine: Extract math & statistical functions into MathExtension (#152266) 2025-09-14 20:48:29 -04:00
J. Nick Koston
b203a831c9 Bump aioesphomeapi to 40.2.1 (#152327) 2025-09-14 19:31:55 -05:00
G Johansson
5ccbee4c9a Break long strings in entity platform/component tests (#152320) 2025-09-14 23:27:04 +02:00
Allen Porter
1483c9488f Update authorization server to prefer absolute urls (#152313)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-14 14:07:31 -07:00
433 changed files with 26084 additions and 4359 deletions

View File

@@ -0,0 +1,77 @@
---
name: quality-scale-rule-verifier
description: |
Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
<example>
Context: The user wants to verify if an integration follows a specific quality scale rule.
user: "Check if the peblar integration follows the config-flow rule"
assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
<commentary>
Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
</commentary>
</example>
<example>
Context: The user is reviewing if an integration reaches a specific quality scale level.
user: "Verify that this integration reaches the bronze quality scale"
assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
<commentary>
The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
</commentary>
</example>
model: inherit
color: yellow
tools: Read, Bash, Grep, Glob, WebFetch
---
You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
You will verify if an integration follows a specific quality scale rule by:
1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
- Core requirements and mandatory implementations
- Specific code patterns or configurations required
- Common violations and anti-patterns
- Exemption criteria (when a rule might not apply)
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/<integration domain>` focusing on:
- `manifest.json` for quality scale declaration and configuration
- `quality_scale.yaml` for rule status (done, todo, exempt)
- Relevant Python modules based on the rule requirements
- Configuration files and service definitions as needed
4. **Verification Process**:
- Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
- If marked 'exempt', verify the exemption reason is valid
- If marked 'done', verify the actual implementation matches requirements
- Identify specific files and code sections that demonstrate compliance or violations
- Consider the integration's declared quality tier when applying rules
- To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
- To fetch information about a PyPI package, use the URL `https://pypi.org/pypi/<package>/json`
5. **Reporting Findings**: Provide a comprehensive verification report that includes:
- **Rule Summary**: Brief description of what the rule requires
- **Compliance Status**: Clear pass/fail/exempt determination
- **Evidence**: Specific code examples showing compliance or violations
- **Issues Found**: Detailed list of any non-compliance issues with file locations
- **Recommendations**: Actionable steps to achieve compliance if needed
- **Exemption Analysis**: If applicable, whether the exemption is justified
When examining code, you will:
- Look for exact implementation patterns specified in the rule
- Verify all required components are present and properly configured
- Check for common mistakes and anti-patterns
- Consider edge cases and error handling requirements
- Validate that implementations follow Home Assistant conventions
You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.

View File

@@ -55,8 +55,12 @@
creating the PR. If you're unsure about any of them, don't hesitate to ask.
We're here to help! This is simply a reminder of what we are going to look
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
-->
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR.
@@ -64,6 +68,7 @@
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [ ] Tests have been added to verify that the new code works.
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
If user exposed functionality or configuration variables are added/changed:

View File

@@ -198,7 +198,7 @@ jobs:
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -265,7 +265,7 @@ jobs:
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -326,7 +326,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Cosign
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.2.3"

View File

@@ -523,22 +523,24 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}-
- name: Restore apt cache
if: steps.cache-venv.outputs.cache-hit != 'true'
id: cache-apt
uses: actions/cache@v4.2.4
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
mkdir -p ${{ env.APT_CACHE_DIR }}
mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
fi
@@ -563,9 +565,18 @@ jobs:
libswscale-dev \
libudev-dev
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |

2
.gitignore vendored
View File

@@ -140,5 +140,5 @@ tmp_cache
pytest_buckets.txt
# AI tooling
.claude
.claude/settings.local.json

View File

@@ -142,6 +142,7 @@ homeassistant.components.cloud.*
homeassistant.components.co2signal.*
homeassistant.components.comelit.*
homeassistant.components.command_line.*
homeassistant.components.compit.*
homeassistant.components.config.*
homeassistant.components.configurator.*
homeassistant.components.cookidoo.*

19
CODEOWNERS generated
View File

@@ -107,8 +107,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/ambient_station/ @bachya
/tests/components/ambient_station/ @bachya
/homeassistant/components/amcrest/ @flacjacket
/homeassistant/components/analytics/ @home-assistant/core @ludeeus
/tests/components/analytics/ @home-assistant/core @ludeeus
/homeassistant/components/analytics/ @home-assistant/core
/tests/components/analytics/ @home-assistant/core
/homeassistant/components/analytics_insights/ @joostlek
/tests/components/analytics_insights/ @joostlek
/homeassistant/components/android_ip_webcam/ @engrbm87
@@ -292,6 +292,8 @@ build.json @home-assistant/supervisor
/tests/components/command_line/ @gjohansson-ST
/homeassistant/components/compensation/ @Petro31
/tests/components/compensation/ @Petro31
/homeassistant/components/compit/ @Przemko92
/tests/components/compit/ @Przemko92
/homeassistant/components/config/ @home-assistant/core
/tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core
@@ -1017,7 +1019,8 @@ build.json @home-assistant/supervisor
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
/homeassistant/components/nest/ @allenporter
@@ -1349,6 +1352,8 @@ build.json @home-assistant/supervisor
/tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/sanix/ @tomaszsluszniak
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core
@@ -1530,8 +1535,8 @@ build.json @home-assistant/supervisor
/tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
/tests/components/switcher_kis/ @thecode @YogevBokobza
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
@@ -1676,6 +1681,8 @@ build.json @home-assistant/supervisor
/tests/components/uptime_kuma/ @tr4nt0r
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
/tests/components/uptimerobot/ @ludeeus @chemelli74
/homeassistant/components/usage_prediction/ @home-assistant/core
/tests/components/usage_prediction/ @home-assistant/core
/homeassistant/components/usb/ @bdraco
/tests/components/usb/ @bdraco
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte
@@ -1705,6 +1712,8 @@ build.json @home-assistant/supervisor
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel

View File

@@ -2,22 +2,31 @@
from __future__ import annotations
from pathlib import Path
from homeassistant.components.media_source import MediaSource, local_source
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up local media source."""
media_dir = hass.config.path(f"{DOMAIN}/{IMAGE_DIR}")
media_dirs = list(hass.config.media_dirs.values())
if not media_dirs:
raise HomeAssistantError(
"AI Task media source requires at least one media directory configured"
)
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI Generated Images",
{IMAGE_DIR: media_dir},
False,
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
return source

View File

@@ -12,7 +12,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import camera, conversation, media_source
from homeassistant.components import camera, conversation, image, media_source
from homeassistant.components.http.auth import async_sign_path
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
@@ -31,14 +31,14 @@ from .const import (
)
def _save_camera_snapshot(image: camera.Image) -> Path:
def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path:
"""Save camera snapshot to temp file."""
with tempfile.NamedTemporaryFile(
mode="wb",
suffix=mimetypes.guess_extension(image.content_type, False),
suffix=mimetypes.guess_extension(image_data.content_type, False),
delete=False,
) as temp_file:
temp_file.write(image.content)
temp_file.write(image_data.content)
return Path(temp_file.name)
@@ -54,26 +54,31 @@ async def _resolve_attachments(
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
# Special case for camera media sources
if media_content_id.startswith("media-source://camera/"):
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix("media-source://camera/")
# Special case for certain media sources
for integration in camera, image:
media_source_prefix = f"media-source://{integration.DOMAIN}/"
if not media_content_id.startswith(media_source_prefix):
continue
# Get snapshot from camera
image = await camera.async_get_image(hass, entity_id)
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix(media_source_prefix)
# Get snapshot from entity
image_data = await integration.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image
_save_camera_snapshot, image_data
)
created_files.append(temp_filename)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image.content_type,
mime_type=image_data.content_type,
path=temp_filename,
)
)
break
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)

View File

@@ -2,7 +2,7 @@
"domain": "analytics",
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core", "@ludeeus"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",

View File

@@ -467,7 +467,10 @@ async def async_setup_entry(
# periodical (or manual) self test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it would ever be available. Here we
# add it anyway and mark it as unknown initially.
for resource in available_resources | {LAST_S_TEST}:
#
# We also sort the resources to ensure the order of entities created is deterministic since
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue

View File

@@ -109,7 +109,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
)
state = await self.async_get_last_state()
if state is not None and state.state in self.options:
if (state is not None) and (state.state in self.options):
self._attr_current_option = state.state
if self.registry_entry and (device_id := self.registry_entry.device_id):
@@ -119,7 +119,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
def cleanup() -> None:
"""Clean up registered device."""
pipeline_data.pipeline_devices.pop(device_id)
pipeline_data.pipeline_devices.pop(device_id, None)
self.async_on_remove(cleanup)

View File

@@ -120,6 +120,7 @@ class AsusWrtBridge(ABC):
def __init__(self, host: str) -> None:
"""Initialize Bridge."""
self._configuration_url = f"http://{host}"
self._host = host
self._firmware: str | None = None
self._label_mac: str | None = None
@@ -127,6 +128,11 @@ class AsusWrtBridge(ABC):
self._model_id: str | None = None
self._serial_number: str | None = None
@property
def configuration_url(self) -> str:
"""Return configuration URL."""
return self._configuration_url
@property
def host(self) -> str:
"""Return hostname."""
@@ -371,6 +377,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
# get main router properties
if mac := _identity.mac:
self._label_mac = format_mac(mac)
self._configuration_url = self._api.webpanel
self._firmware = str(_identity.firmware)
self._model = _identity.model
self._model_id = _identity.product_id

View File

@@ -388,13 +388,13 @@ class AsusWrtRouter:
def device_info(self) -> DeviceInfo:
"""Return the device information."""
info = DeviceInfo(
configuration_url=self._api.configuration_url,
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
name=self.host,
model=self._api.model or "Asus Router",
model_id=self._api.model_id,
serial_number=self._api.serial_number,
manufacturer="Asus",
configuration_url=f"http://{self.host}",
)
if self._api.firmware:
info["sw_version"] = self._api.firmware

View File

@@ -2,13 +2,12 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
import logging
from typing import Any
from aiohttp import ClientResponseError
from yalexs.activity import ActivityType, ActivityTypes
from yalexs.lock import Lock, LockStatus
from yalexs.activity import ActivityType
from yalexs.lock import Lock, LockOperation, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
@@ -50,30 +49,25 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
if self._data.push_updates_connected:
await self._data.async_lock_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_lock)
await self._perform_lock_operation(LockOperation.LOCK)
async def async_open(self, **kwargs: Any) -> None:
"""Open/unlatch the device."""
if self._data.push_updates_connected:
await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_unlatch)
await self._perform_lock_operation(LockOperation.OPEN)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
if self._data.push_updates_connected:
await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_unlock)
await self._perform_lock_operation(LockOperation.UNLOCK)
async def _call_lock_operation(
self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
) -> None:
async def _perform_lock_operation(self, operation: LockOperation) -> None:
"""Perform a lock operation."""
try:
activities = await lock_operation(self._device_id)
activities = await self._data.async_operate_lock(
self._device_id,
operation,
self._data.push_updates_connected,
self._hyper_bridge,
)
except ClientResponseError as err:
if err.status == LOCK_JAMMED_ERR:
self._detail.lock_status = LockStatus.JAMMED

View File

@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
}

View File

@@ -92,7 +92,11 @@ from homeassistant.components.http.ban import (
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.network import (
NoURLAvailableError,
get_url,
is_cloud_connection,
)
from homeassistant.util.network import is_local
from . import indieauth
@@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
"""Return the well known OAuth2 authorization info."""
hass = request.app[KEY_HASS]
# Some applications require absolute urls, so we prefer using the
# current requests url if possible, with fallback to a relative url.
try:
url_prefix = get_url(hass, require_current_request=True)
except NoURLAvailableError:
url_prefix = ""
return self.json(
{
"authorization_endpoint": "/auth/authorize",
"token_endpoint": "/auth/token",
"revocation_endpoint": "/auth/revoke",
"authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke",
"response_types_supported": ["code"],
"service_documentation": (
"https://developers.home-assistant.io/docs/auth_api"

View File

@@ -26,7 +26,6 @@ EXCLUDE_FROM_BACKUP = [
"tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
"ai_task/*",
]
EXCLUDE_DATABASE_FROM_BACKUP = [

View File

@@ -8,7 +8,7 @@ import threading
from typing import IO, cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
from aiohttp.web import FileResponse, Request, Response, StreamResponse
from multidict import istr
@@ -76,7 +76,8 @@ class DownloadBackupView(HomeAssistantView):
return Response(status=HTTPStatus.NOT_FOUND)
headers = {
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
CONTENT_TYPE: "application/x-tar",
}
try:

View File

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

View File

@@ -25,6 +25,7 @@ from homeassistant.const import (
DEGREE,
LIGHT_LUX,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfConductivity,
@@ -269,6 +270,15 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
),
# Rotational speed (rpm)
(
BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED,
Units.REVOLUTIONS_PER_MINUTE,
): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED}_{Units.REVOLUTIONS_PER_MINUTE}",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
),
# Signal Strength (RSSI) (dB)
(
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,

View File

@@ -37,6 +37,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.loader import (
async_get_custom_components,
async_get_loaded_integration,
)
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
"""Collect information about active and custom integrations."""
# Get loaded components from hass.config.components
loaded_components = hass.config.components.copy()
# Get custom integrations
custom_domains = set()
with suppress(Exception):
custom_domains = set(await async_get_custom_components(hass))
# Separate built-in and custom integrations
builtin_integrations = []
custom_integrations = []
for domain in sorted(loaded_components):
try:
integration = async_get_loaded_integration(hass, domain)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package
# generation. If we can't get integration info,
# just add the domain
if domain in custom_domains:
custom_integrations.append(
{
"domain": domain,
"name": "Unknown",
"version": "Unknown",
"documentation": "Unknown",
}
)
else:
builtin_integrations.append(
{
"domain": domain,
"name": "Unknown",
}
)
else:
if domain in custom_domains:
# This is a custom integration
# include version and documentation link
version = (
str(integration.version) if integration.version else "Unknown"
)
if not (documentation := integration.documentation):
documentation = "Unknown"
custom_integrations.append(
{
"domain": domain,
"name": integration.name,
"version": version,
"documentation": documentation,
}
)
else:
# This is a built-in integration.
# No version needed, as it is always the same as the
# Home Assistant version
builtin_integrations.append(
{
"domain": domain,
"name": integration.name,
}
)
return {
"builtin_count": len(builtin_integrations),
"builtin_integrations": builtin_integrations,
"custom_count": len(custom_integrations),
"custom_integrations": custom_integrations,
}
async def _generate_markdown(
self,
hass: HomeAssistant,
@@ -453,6 +530,38 @@ class DownloadSupportPackageView(HomeAssistantView):
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
# Add integration information
try:
integration_info = await self._get_integration_info(hass)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
# If there's any error getting integration info, just note it
markdown += "## Active integrations\n\n"
markdown += "Unable to collect integration information\n\n"
else:
markdown += "## Active Integrations\n\n"
markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
# Built-in integrations
if integration_info["builtin_integrations"]:
markdown += "<details><summary>Built-in integrations</summary>\n\n"
markdown += "Domain | Name\n"
markdown += "--- | ---\n"
for integration in integration_info["builtin_integrations"]:
markdown += f"{integration['domain']} | {integration['name']}\n"
markdown += "\n</details>\n\n"
# Custom integrations
if integration_info["custom_integrations"]:
markdown += "<details><summary>Custom integrations</summary>\n\n"
markdown += "Domain | Name | Version | Documentation\n"
markdown += "--- | --- | --- | ---\n"
for integration in integration_info["custom_integrations"]:
doc_url = integration.get("documentation") or "N/A"
markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
markdown += "\n</details>\n\n"
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (

View File

@@ -2,7 +2,7 @@
from abc import abstractmethod
from datetime import timedelta
from typing import TypeVar
from typing import Any, TypeVar
from aiocomelit.api import (
AlarmDataObject,
@@ -13,7 +13,16 @@ from aiocomelit.api import (
ComelitVedoAreaObject,
ComelitVedoZoneObject,
)
from aiocomelit.const import BRIDGE, VEDO
from aiocomelit.const import (
BRIDGE,
CLIMATE,
COVER,
IRRIGATION,
LIGHT,
OTHER,
SCENARIO,
VEDO,
)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession
@@ -111,6 +120,32 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
async def _async_update_system_data(self) -> T:
"""Class method for updating data."""
async def _async_remove_stale_devices(
self,
previous_list: dict[int, Any],
current_list: dict[int, Any],
dev_type: str,
) -> None:
"""Remove stale devices."""
device_registry = dr.async_get(self.hass)
for i in previous_list:
if i not in current_list:
_LOGGER.debug(
"Detected change in %s devices: index %s removed",
dev_type,
i,
)
identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}"
device = device_registry.async_get_device(
identifiers={(DOMAIN, identifier)}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
@@ -137,7 +172,15 @@ class ComelitSerialBridge(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data."""
return await self.api.get_all_devices()
data = await self.api.get_all_devices()
if self.data:
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
await self._async_remove_stale_devices(
self.data[dev_type], data[dev_type], dev_type
)
return data
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
@@ -163,4 +206,14 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
self,
) -> AlarmDataObject:
"""Specific method for updating data."""
return await self.api.get_all_areas_and_zones()
data = await self.api.get_all_areas_and_zones()
if self.data:
for obj_type in ("alarm_areas", "alarm_zones"):
await self._async_remove_stale_devices(
self.data[obj_type],
data[obj_type],
"area" if obj_type == "alarm_areas" else "zone",
)
return data

View File

@@ -72,9 +72,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: todo
comment: missing implementation
stale-devices: done
# Platinum
async-dependency: done

View File

@@ -0,0 +1,45 @@
"""The Compit integration."""
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.CLIMATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
"""Set up Compit from a config entry."""
session = async_get_clientsession(hass)
connector = CompitApiConnector(session)
try:
connected = await connector.init(
entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language
)
except CannotConnect as e:
raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
f"Invalid credentials for {entry.data[CONF_EMAIL]}"
) from e
if not connected:
raise ConfigEntryAuthFailed("Authentication API error")
coordinator = CompitDataUpdateCoordinator(hass, entry, connector)
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: CompitConfigEntry) -> bool:
"""Unload an entry for the Compit integration."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,264 @@
"""Module contains the CompitClimate class for controlling climate entities."""
import logging
from typing import Any
from compit_inext_api import Param, Parameter
from compit_inext_api.consts import (
CompitFanMode,
CompitHVACMode,
CompitParameter,
CompitPresetMode,
)
from propcache.api import cached_property
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
PRESET_AWAY,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
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, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Device class for climate devices in Compit system
CLIMATE_DEVICE_CLASS = 10
PARALLEL_UPDATES = 0
COMPIT_MODE_MAP = {
CompitHVACMode.COOL: HVACMode.COOL,
CompitHVACMode.HEAT: HVACMode.HEAT,
CompitHVACMode.OFF: HVACMode.OFF,
}
COMPIT_FANSPEED_MAP = {
CompitFanMode.OFF: FAN_OFF,
CompitFanMode.AUTO: FAN_AUTO,
CompitFanMode.LOW: FAN_LOW,
CompitFanMode.MEDIUM: FAN_MEDIUM,
CompitFanMode.HIGH: FAN_HIGH,
CompitFanMode.HOLIDAY: FAN_AUTO,
}
COMPIT_PRESET_MAP = {
CompitPresetMode.AUTO: PRESET_HOME,
CompitPresetMode.HOLIDAY: PRESET_ECO,
CompitPresetMode.MANUAL: PRESET_NONE,
CompitPresetMode.AWAY: PRESET_AWAY,
}
HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CompitClimate platform from a config entry."""
coordinator = entry.runtime_data
climate_entities = []
for device_id in coordinator.connector.devices:
device = coordinator.connector.devices[device_id]
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
climate_entities.append(
CompitClimate(
coordinator,
device_id,
{
parameter.parameter_code: parameter
for parameter in device.definition.parameters
},
device.definition.name,
)
)
async_add_devices(climate_entities)
class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
"""Representation of a Compit climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
_attr_name = None
_attr_has_entity_name = True
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
)
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
parameters: dict[str, Parameter],
device_name: str,
) -> None:
"""Initialize the climate device."""
super().__init__(coordinator)
self._attr_unique_id = f"{device_name}_{device_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
self.parameters = parameters
self.device_id = device_id
self.available_presets: Parameter | None = self.parameters.get(
CompitParameter.PRESET_MODE.value
)
self.available_fan_modes: Parameter | None = self.parameters.get(
CompitParameter.FAN_MODE.value
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available and self.device_id in self.coordinator.connector.devices
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
if value is None:
return None
return float(value.value)
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
if value is None:
return None
return float(value.value)
@cached_property
def preset_modes(self) -> list[str] | None:
"""Return the available preset modes."""
if self.available_presets is None or self.available_presets.details is None:
return []
preset_modes = []
for item in self.available_presets.details:
if item is not None:
ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
if ha_preset and ha_preset not in preset_modes:
preset_modes.append(ha_preset)
return preset_modes
@cached_property
def fan_modes(self) -> list[str] | None:
"""Return the available fan modes."""
if self.available_fan_modes is None or self.available_fan_modes.details is None:
return []
fan_modes = []
for item in self.available_fan_modes.details:
if item is not None:
ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
if ha_fan_mode and ha_fan_mode not in fan_modes:
fan_modes.append(ha_fan_mode)
return fan_modes
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
if preset_mode:
compit_preset_mode = CompitPresetMode(preset_mode.value)
return COMPIT_PRESET_MAP.get(compit_preset_mode)
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
if fan_mode:
compit_fan_mode = CompitFanMode(fan_mode.value)
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
return None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
if hvac_mode:
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
return COMPIT_MODE_MAP.get(compit_hvac_mode)
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is None:
raise ServiceValidationError("Temperature argument missing")
await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)):
raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}")
await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode)
if compit_preset is None:
raise ServiceValidationError(f"Invalid preset mode: {preset_mode}")
await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode)
if compit_fan_mode is None:
raise ServiceValidationError(f"Invalid fan mode: {fan_mode}")
await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value)
async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None:
"""Call the API to set a parameter to a new value."""
await self.coordinator.connector.set_device_parameter(
self.device_id, parameter, value
)
self.async_write_ha_state()
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
"""Get the parameter value from the device state."""
return self.coordinator.connector.get_device_parameter(
self.device_id, parameter
)

View File

@@ -0,0 +1,110 @@
"""Config flow for Compit integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Compit."""
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:
session = async_create_clientsession(self.hass)
api = CompitApiConnector(session)
success = False
try:
success = await api.init(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
self.hass.config.language,
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not success:
# Api returned unexpected result but no exception
_LOGGER.error("Compit api returned unexpected result")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_EMAIL])
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL], 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, data: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle re-auth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
reauth_entry_data = reauth_entry.data
if user_input:
# Reuse async_step_user with combined credentials
return await self.async_step_user(
{
CONF_EMAIL: reauth_entry_data[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]},
errors=errors,
)

View File

@@ -0,0 +1,4 @@
"""Constants for the Compit integration."""
DOMAIN = "compit"
MANUFACTURER_NAME = "Compit"

View File

@@ -0,0 +1,43 @@
"""Define an object to manage fetching Compit data."""
from datetime import timedelta
import logging
from compit_inext_api import CompitApiConnector, DeviceInstance
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER: logging.Logger = logging.getLogger(__name__)
type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator]
class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
connector: CompitApiConnector,
) -> None:
"""Initialize."""
self.connector = connector
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
async def _async_update_data(self) -> dict[int, DeviceInstance]:
"""Update data via library."""
await self.connector.update_state(device_id=None) # Update all devices
return self.connector.devices

View File

@@ -0,0 +1,12 @@
{
"domain": "compit",
"name": "Compit",
"codeowners": ["@Przemko92"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/compit",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.2.1"]
}

View File

@@ -0,0 +1,86 @@
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 does not use any common modules.
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: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration is a cloud service and does not support discovery.
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that should disabled by default.
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
comment: |
There is no need for icon translations.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done

View File

@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "Please enter your https://inext.compit.pl/ credentials.",
"title": "Connect to Compit iNext",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address of your inext.compit.pl account",
"password": "The password of your inext.compit.pl account"
}
},
"reauth_confirm": {
"description": "Please update your password for {email}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::compit::config::step::user::data_description::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@@ -50,14 +50,13 @@ from .const import (
ATTR_LANGUAGE,
ATTR_TEXT,
DATA_COMPONENT,
DATA_DEFAULT_ENTITY,
DOMAIN,
HOME_ASSISTANT_AGENT,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
)
from .default_agent import DefaultAgent, async_setup_default_agent
from .default_agent import async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
@@ -142,7 +141,7 @@ def async_unset_agent(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
"""Set the agent to handle the conversations."""
"""Unset the agent to handle the conversations."""
get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
@@ -241,10 +240,10 @@ async def async_handle_sentence_triggers(
Returns None if no match occurred.
"""
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
agent = get_agent_manager(hass).default_agent
assert agent is not None
return await default_agent.async_handle_sentence_triggers(user_input)
return await agent.async_handle_sentence_triggers(user_input)
async def async_handle_intents(
@@ -257,12 +256,10 @@ async def async_handle_intents(
Returns None if no match occurred.
"""
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
agent = get_agent_manager(hass).default_agent
assert agent is not None
return await default_agent.async_handle_intents(
user_input, intent_filter=intent_filter
)
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -298,9 +295,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
await hass.data[DATA_DEFAULT_ENTITY].async_reload(
language=service.data.get(ATTR_LANGUAGE)
)
agent = get_agent_manager(hass).default_agent
if agent is not None:
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
hass.services.async_register(
DOMAIN,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import dataclasses
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -12,7 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent, singleton
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
from .entity import ConversationEntity
from .models import (
AbstractConversationAgent,
@@ -28,6 +28,9 @@ from .trace import (
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .default_agent import DefaultAgent
@singleton.singleton("conversation_agent")
@callback
@@ -49,8 +52,10 @@ def async_get_agent(
hass: HomeAssistant, agent_id: str | None = None
) -> AbstractConversationAgent | ConversationEntity | None:
"""Get specified agent."""
manager = get_agent_manager(hass)
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
return hass.data[DATA_DEFAULT_ENTITY]
return manager.default_agent
if "." in agent_id:
return hass.data[DATA_COMPONENT].get_entity(agent_id)
@@ -134,6 +139,7 @@ class AgentManager:
"""Initialize the conversation agents."""
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
@callback
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
@@ -182,3 +188,7 @@ class AgentManager:
def async_unset_agent(self, agent_id: str) -> None:
"""Unset the agent."""
self._agents.pop(agent_id, None)
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
self.default_agent = agent

View File

@@ -10,11 +10,9 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent
from .default_agent import DefaultAgent
from .entity import ConversationEntity
DOMAIN = "conversation"
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
ATTR_TEXT = "text"
@@ -26,7 +24,6 @@ SERVICE_PROCESS = "process"
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
class ConversationEntityFeature(IntFlag):

View File

@@ -68,13 +68,9 @@ from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
DOMAIN,
ConversationEntityFeature,
)
from .const import DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -83,6 +79,8 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[
[ConversationInput, RecognizeResult], Awaitable[str | None]
@@ -209,9 +207,9 @@ async def async_setup_default_agent(
config_intents: dict[str, Any],
) -> None:
"""Set up entity registry listener for the default agent."""
entity = DefaultAgent(hass, config_intents)
await entity_component.async_add_entities([entity])
hass.data[DATA_DEFAULT_ENTITY] = entity
agent = DefaultAgent(hass, config_intents)
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@core.callback
def async_entity_state_listener(
@@ -846,7 +844,7 @@ class DefaultAgent(ConversationEntity):
context = {"domain": state.domain}
if state.attributes:
# Include some attributes
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
for attr in _DEFAULT_EXPOSED_ATTRIBUTES:
if attr not in state.attributes:
continue
context[attr] = state.attributes[attr]

View File

@@ -25,7 +25,7 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
from .const import DATA_COMPONENT
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
@@ -169,7 +169,8 @@ async def websocket_list_sentences(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""List custom registered sentences."""
agent = hass.data[DATA_DEFAULT_ENTITY]
agent = get_agent_manager(hass).default_agent
assert agent is not None
sentences = []
for trigger_data in agent.trigger_sentences:
@@ -191,7 +192,8 @@ async def websocket_hass_agent_debug(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return intents that would be matched by the default agent for a list of sentences."""
agent = hass.data[DATA_DEFAULT_ENTITY]
agent = get_agent_manager(hass).default_agent
assert agent is not None
# Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = []

View File

@@ -20,7 +20,8 @@ from homeassistant.helpers.script import ScriptRunResult
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import UNDEFINED, ConfigType
from .const import DATA_DEFAULT_ENTITY, DOMAIN
from .agent_manager import get_agent_manager
from .const import DOMAIN
from .models import ConversationInput
@@ -123,4 +124,6 @@ async def async_attach_trigger(
# two trigger copies for who will provide a response.
return None
return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action)
agent = get_agent_manager(hass).default_agent
assert agent is not None
return agent.register_trigger(sentences, call_action)

View File

@@ -19,6 +19,7 @@
"ssdp",
"stream",
"sun",
"usage_prediction",
"usb",
"webhook",
"zeroconf"

View File

@@ -43,3 +43,5 @@ class DelugeSensorType(enum.StrEnum):
UPLOAD_SPEED_SENSOR = "upload_speed"
PROTOCOL_TRAFFIC_UPLOAD_SPEED_SENSOR = "protocol_traffic_upload_speed"
PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR = "protocol_traffic_download_speed"
DOWNLOADING_COUNT_SENSOR = "downloading_count"
SEEDING_COUNT_SENSOR = "seeding_count"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections import Counter
from datetime import timedelta
from ssl import SSLError
from typing import Any
@@ -14,11 +15,22 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER, DelugeGetSessionStatusKeys
from .const import LOGGER, DelugeGetSessionStatusKeys, DelugeSensorType
type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator]
def count_states(data: dict[str, Any]) -> dict[str, int]:
"""Count the states of the provided torrents."""
counts = Counter(torrent[b"state"].decode() for torrent in data.values())
return {
DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value: counts.get("Downloading", 0),
DelugeSensorType.SEEDING_COUNT_SENSOR.value: counts.get("Seeding", 0),
}
class DelugeDataUpdateCoordinator(
DataUpdateCoordinator[dict[Platform, dict[str, Any]]]
):
@@ -39,19 +51,22 @@ class DelugeDataUpdateCoordinator(
)
self.api = api
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
"""Get the latest data from Deluge and updates the state."""
def _get_deluge_data(self):
"""Get the latest data from Deluge."""
data = {}
try:
_data = await self.hass.async_add_executor_job(
self.api.call,
data["session_status"] = self.api.call(
"core.get_session_status",
[iter_member.value for iter_member in list(DelugeGetSessionStatusKeys)],
)
data[Platform.SENSOR] = {k.decode(): v for k, v in _data.items()}
data[Platform.SWITCH] = await self.hass.async_add_executor_job(
self.api.call, "core.get_torrents_status", {}, ["paused"]
data["torrents_status_state"] = self.api.call(
"core.get_torrents_status", {}, ["state"]
)
data["torrents_status_paused"] = self.api.call(
"core.get_torrents_status", {}, ["paused"]
)
except (
ConnectionRefusedError,
TimeoutError,
@@ -66,4 +81,18 @@ class DelugeDataUpdateCoordinator(
) from ex
LOGGER.error("Unknown error connecting to Deluge: %s", ex)
raise
return data
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
"""Get the latest data from Deluge and updates the state."""
deluge_data = await self.hass.async_add_executor_job(self._get_deluge_data)
data = {}
data[Platform.SENSOR] = {
k.decode(): v for k, v in deluge_data["session_status"].items()
}
data[Platform.SENSOR].update(count_states(deluge_data["torrents_status_state"]))
data[Platform.SWITCH] = deluge_data["torrents_status_paused"]
return data

View File

@@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"downloading_count": {
"default": "mdi:download"
},
"seeding_count": {
"default": "mdi:upload"
}
}
}
}

View File

@@ -110,6 +110,18 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
data, DelugeSensorType.PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR.value
),
),
DelugeSensorEntityDescription(
key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
translation_key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
state_class=SensorStateClass.TOTAL,
value=lambda data: data[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value],
),
DelugeSensorEntityDescription(
key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
translation_key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
state_class=SensorStateClass.TOTAL,
value=lambda data: data[DelugeSensorType.SEEDING_COUNT_SENSOR.value],
),
)

View File

@@ -36,6 +36,10 @@
"idle": "[%key:common::state::idle%]"
}
},
"downloading_count": {
"name": "Downloading count",
"unit_of_measurement": "torrents"
},
"download_speed": {
"name": "Download speed"
},
@@ -45,6 +49,10 @@
"protocol_traffic_upload_speed": {
"name": "Protocol traffic upload speed"
},
"seeding_count": {
"name": "Seeding count",
"unit_of_measurement": "[%key:component::deluge::entity::sensor::downloading_count::unit_of_measurement%]"
},
"upload_speed": {
"name": "Upload speed"
}

View File

@@ -0,0 +1,23 @@
"""Diagnostics support for derivative."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
registry = er.async_get(hass)
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
return {
"config_entry": config_entry.as_dict(),
"entity": [entity.extended_dict for entity in entities],
}

View File

@@ -227,15 +227,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
weight = calculate_weight(start, end, current_time)
derivative = derivative + (value * Decimal(weight))
_LOGGER.debug(
"%s: Calculated new derivative as %f from %d segments",
self.entity_id,
derivative,
len(self._state_list),
)
return derivative
def _prune_state_list(self, current_time: datetime) -> None:
# filter out all derivatives older than `time_window` from our window list
old_len = len(self._state_list)
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
if (current_time - time_end).total_seconds() < self._time_window
]
_LOGGER.debug(
"%s: Pruned %d elements from state list",
self.entity_id,
old_len - len(self._state_list),
)
def _handle_invalid_source_state(self, state: State | None) -> bool:
# Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false.
@@ -292,6 +305,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
) -> None:
"""Calculate derivative based on time and reschedule."""
_LOGGER.debug(
"%s: Recalculating derivative due to max_sub_interval time elapsed",
self.entity_id,
)
self._prune_state_list(now)
derivative = self._calc_derivative_from_state_list(now)
self._write_native_value(derivative)
@@ -300,6 +317,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
if derivative != 0:
schedule_max_sub_interval_exceeded(source_state)
_LOGGER.debug(
"%s: Scheduling max_sub_interval_callback in %s",
self.entity_id,
self._max_sub_interval,
)
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
self.hass,
self._max_sub_interval,
@@ -309,6 +331,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
@callback
def on_state_reported(event: Event[EventStateReportedData]) -> None:
"""Handle constant sensor state."""
_LOGGER.debug(
"%s: New state reported event: %s", self.entity_id, event.data
)
self._cancel_max_sub_interval_exceeded_callback()
new_state = event.data["new_state"]
if not self._handle_invalid_source_state(new_state):
@@ -330,6 +355,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
@callback
def on_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle changed sensor state."""
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
self._cancel_max_sub_interval_exceeded_callback()
new_state = event.data["new_state"]
if not self._handle_invalid_source_state(new_state):
@@ -382,15 +408,32 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
/ Decimal(self._unit_prefix)
* Decimal(self._unit_time)
)
_LOGGER.debug(
"%s: Calculated new derivative segment as %f / %f / %f * %f = %f",
self.entity_id,
delta_value,
elapsed_time,
self._unit_prefix,
self._unit_time,
new_derivative,
)
except ValueError as err:
_LOGGER.warning("While calculating derivative: %s", err)
_LOGGER.warning(
"%s: While calculating derivative: %s", self.entity_id, err
)
except DecimalException as err:
_LOGGER.warning(
"Invalid state (%s > %s): %s", old_value, new_state.state, err
"%s: Invalid state (%s > %s): %s",
self.entity_id,
old_value,
new_state.state,
err,
)
except AssertionError as err:
_LOGGER.error("Could not calculate derivative: %s", err)
_LOGGER.error(
"%s: Could not calculate derivative: %s", self.entity_id, err
)
# For total inreasing sensors, the value is expected to continuously increase.
# A negative derivative for a total increasing sensor likely indicates the
@@ -400,6 +443,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
== SensorStateClass.TOTAL_INCREASING
and new_derivative < 0
):
_LOGGER.debug(
"%s: Dropping sample as source total_increasing sensor decreased",
self.entity_id,
)
return
# add latest derivative to the window list

View File

@@ -234,6 +234,17 @@ ECOWITT_SENSORS_MAPPING: Final = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription(
key="DISTANCE_MM",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription(
key="HEAT_COUNT",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
EcoWittSensorTypes.PM1: SensorEntityDescription(
key="PM1",
device_class=SensorDeviceClass.PM1,

View File

@@ -120,6 +120,14 @@ def _make_url_from_data(data: dict[str, str]) -> str:
return f"{protocol}{address}"
def _get_protocol_from_url(url: str) -> str:
"""Get protocol from URL. Returns the configured protocol from URL or the default secure protocol."""
return next(
(k for k, v in PROTOCOL_MAP.items() if url.startswith(v)),
DEFAULT_SECURE_PROTOCOL,
)
def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
return {
"mac_address": _short_mac(device.mac_address),
@@ -205,6 +213,78 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
)
return await self.async_step_discovered_connection()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
existing_data = reconfigure_entry.data
if user_input is not None:
validate_input_data = dict(user_input)
validate_input_data[CONF_PREFIX] = existing_data.get(CONF_PREFIX, "")
try:
info = await validate_input(
validate_input_data, reconfigure_entry.unique_id
)
except TimeoutError:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors[CONF_PASSWORD] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception during reconfiguration")
errors["base"] = "unknown"
else:
# Discover the device at the provided address to obtain its MAC (unique_id)
device = await async_discover_device(
self.hass, validate_input_data[CONF_ADDRESS]
)
if device is not None and device.mac_address:
await self.async_set_unique_id(dr.format_mac(device.mac_address))
self._abort_if_unique_id_mismatch() # aborts if user tried to switch devices
else:
# If we cannot confirm identity, keep existing behavior (don't block reconfigure)
await self.async_set_unique_id(reconfigure_entry.unique_id)
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
**reconfigure_entry.data,
CONF_HOST: info[CONF_HOST],
CONF_USERNAME: validate_input_data[CONF_USERNAME],
CONF_PASSWORD: validate_input_data[CONF_PASSWORD],
CONF_PREFIX: info[CONF_PREFIX],
},
reason="reconfigure_successful",
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Optional(
CONF_USERNAME,
default=existing_data.get(CONF_USERNAME, ""),
): str,
vol.Optional(
CONF_PASSWORD,
default="",
): str,
vol.Required(
CONF_ADDRESS,
default=hostname_from_url(existing_data[CONF_HOST]),
): str,
vol.Required(
CONF_PROTOCOL,
default=_get_protocol_from_url(existing_data[CONF_HOST]),
): vol.In(ALL_PROTOCOLS),
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -249,12 +329,14 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(user_input, self.unique_id)
except TimeoutError:
except TimeoutError as ex:
_LOGGER.debug("Connection timed out: %s", ex)
return {"base": "cannot_connect"}, None
except InvalidAuth:
except InvalidAuth as ex:
_LOGGER.debug("Invalid auth for %s: %s", user_input.get(CONF_HOST), ex)
return {CONF_PASSWORD: "invalid_auth"}, None
except Exception:
_LOGGER.exception("Unexpected exception")
_LOGGER.exception("Unexpected error validating input")
return {"base": "unknown"}, None
if importing:

View File

@@ -17,8 +17,8 @@
"address": "The IP address or domain or serial port if connecting via serial.",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"prefix": "A unique prefix (leave blank if you only have one ElkM1).",
"temperature_unit": "The temperature unit ElkM1 uses."
"prefix": "A unique prefix (leave blank if you only have one Elk-M1).",
"temperature_unit": "The temperature unit Elk-M1 uses."
}
},
"discovered_connection": {
@@ -30,6 +30,16 @@
"password": "[%key:common::config_flow::data::password%]",
"temperature_unit": "[%key:component::elkm1::config::step::manual_connection::data::temperature_unit%]"
}
},
"reconfigure": {
"title": "Reconfigure Elk-M1 Control",
"description": "[%key:component::elkm1::config::step::manual_connection::description%]",
"data": {
"protocol": "[%key:component::elkm1::config::step::manual_connection::data::protocol%]",
"address": "[%key:component::elkm1::config::step::manual_connection::data::address%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
@@ -42,8 +52,10 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "An ElkM1 with this prefix is already configured",
"address_already_configured": "An ElkM1 with this address is already configured"
"already_configured": "An Elk-M1 with this prefix is already configured",
"address_already_configured": "An Elk-M1 with this address is already configured",
"reconfigure_successful": "Successfully reconfigured Elk-M1 integration",
"unique_id_mismatch": "Reconfigure should be used for the same device not a new one"
}
},
"services": {
@@ -69,7 +81,7 @@
},
"alarm_arm_home_instant": {
"name": "Alarm arm home instant",
"description": "Arms the ElkM1 in home instant mode.",
"description": "Arms the Elk-M1 in home instant mode.",
"fields": {
"code": {
"name": "Code",
@@ -79,7 +91,7 @@
},
"alarm_arm_night_instant": {
"name": "Alarm arm night instant",
"description": "Arms the ElkM1 in night instant mode.",
"description": "Arms the Elk-M1 in night instant mode.",
"fields": {
"code": {
"name": "Code",
@@ -89,7 +101,7 @@
},
"alarm_arm_vacation": {
"name": "Alarm arm vacation",
"description": "Arms the ElkM1 in vacation mode.",
"description": "Arms the Elk-M1 in vacation mode.",
"fields": {
"code": {
"name": "Code",
@@ -99,7 +111,7 @@
},
"alarm_display_message": {
"name": "Alarm display message",
"description": "Displays a message on all of the ElkM1 keypads for an area.",
"description": "Displays a message on all of the Elk-M1 keypads for an area.",
"fields": {
"clear": {
"name": "Clear",
@@ -135,7 +147,7 @@
},
"speak_phrase": {
"name": "Speak phrase",
"description": "Speaks a phrase. See list of phrases in ElkM1 ASCII Protocol documentation.",
"description": "Speaks a phrase. See list of phrases in Elk-M1 ASCII Protocol documentation.",
"fields": {
"number": {
"name": "Phrase number",
@@ -149,7 +161,7 @@
},
"speak_word": {
"name": "Speak word",
"description": "Speaks a word. See list of words in ElkM1 ASCII Protocol documentation.",
"description": "Speaks a word. See list of words in Elk-M1 ASCII Protocol documentation.",
"fields": {
"number": {
"name": "Word number",

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"iot_class": "local_polling",
"requirements": ["pyemoncms==0.1.2"]
"requirements": ["pyemoncms==0.1.3"]
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["pyemoncms==0.1.2"]
"requirements": ["pyemoncms==0.1.3"]
}

View File

@@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
f"[{eq3_config.mac_address}] Device could not be found"
)
thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
thermostat = Thermostat(device)
entry.runtime_data = Eq3ConfigEntryData(
eq3_config=eq3_config, thermostat=thermostat

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.3.0"]
"requirements": ["eq3btsmart==2.3.0"]
}

View File

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

View File

@@ -0,0 +1,39 @@
"""Sensor entities for Geocaching."""
from typing import cast
from geocachingapi.models import GeocachingCache
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GeocachingDataUpdateCoordinator
# Base class for all platforms
class GeocachingBaseEntity(CoordinatorEntity[GeocachingDataUpdateCoordinator]):
"""Base class for Geocaching sensors."""
_attr_has_entity_name = True
# Base class for cache entities
class GeocachingCacheEntity(GeocachingBaseEntity):
"""Base class for Geocaching cache entities."""
def __init__(
self, coordinator: GeocachingDataUpdateCoordinator, cache: GeocachingCache
) -> None:
"""Initialize the Geocaching cache entity."""
super().__init__(coordinator)
self.cache = cache
# A device can have multiple entities, and for a cache which requires multiple entities we want to group them together.
# Therefore, we create a device for each cache, which holds all related entities.
self._attr_device_info = DeviceInfo(
name=f"Geocache {cache.name}",
identifiers={(DOMAIN, cast(str, cache.reference_code))},
entry_type=DeviceEntryType.SERVICE,
manufacturer=cache.owner.username,
)

View File

@@ -15,6 +15,24 @@
},
"awarded_favorite_points": {
"default": "mdi:heart"
},
"cache_name": {
"default": "mdi:label"
},
"cache_owner": {
"default": "mdi:account"
},
"cache_found_date": {
"default": "mdi:calendar-search"
},
"cache_found": {
"default": "mdi:package-variant-closed-check"
},
"cache_favorite_points": {
"default": "mdi:star-check"
},
"cache_hidden_date": {
"default": "mdi:calendar-badge"
}
}
}

View File

@@ -4,18 +4,25 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import datetime
from typing import cast
from geocachingapi.models import GeocachingStatus
from geocachingapi.models import GeocachingCache, GeocachingStatus
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator
from .entity import GeocachingBaseEntity, GeocachingCacheEntity
@dataclass(frozen=True, kw_only=True)
@@ -25,43 +32,63 @@ class GeocachingSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[GeocachingStatus], str | int | None]
SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
PROFILE_SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
GeocachingSensorEntityDescription(
key="find_count",
translation_key="find_count",
native_unit_of_measurement="caches",
value_fn=lambda status: status.user.find_count,
),
GeocachingSensorEntityDescription(
key="hide_count",
translation_key="hide_count",
native_unit_of_measurement="caches",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.hide_count,
),
GeocachingSensorEntityDescription(
key="favorite_points",
translation_key="favorite_points",
native_unit_of_measurement="points",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.favorite_points,
),
GeocachingSensorEntityDescription(
key="souvenir_count",
translation_key="souvenir_count",
native_unit_of_measurement="souvenirs",
value_fn=lambda status: status.user.souvenir_count,
),
GeocachingSensorEntityDescription(
key="awarded_favorite_points",
translation_key="awarded_favorite_points",
native_unit_of_measurement="points",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.awarded_favorite_points,
),
)
@dataclass(frozen=True, kw_only=True)
class GeocachingCacheSensorDescription(SensorEntityDescription):
"""Define Sensor entity description class."""
value_fn: Callable[[GeocachingCache], StateType | datetime.date]
CACHE_SENSORS: tuple[GeocachingCacheSensorDescription, ...] = (
GeocachingCacheSensorDescription(
key="found_date",
device_class=SensorDeviceClass.DATE,
value_fn=lambda cache: cache.found_date_time,
),
GeocachingCacheSensorDescription(
key="favorite_points",
value_fn=lambda cache: cache.favorite_points,
),
GeocachingCacheSensorDescription(
key="hidden_date",
device_class=SensorDeviceClass.DATE,
value_fn=lambda cache: cache.hidden_date,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GeocachingConfigEntry,
@@ -69,14 +96,68 @@ async def async_setup_entry(
) -> None:
"""Set up a Geocaching sensor entry."""
coordinator = entry.runtime_data
async_add_entities(
GeocachingSensor(coordinator, description) for description in SENSORS
entities: list[Entity] = []
entities.extend(
GeocachingProfileSensor(coordinator, description)
for description in PROFILE_SENSORS
)
status = coordinator.data
class GeocachingSensor(
CoordinatorEntity[GeocachingDataUpdateCoordinator], SensorEntity
):
# Add entities for tracked caches
entities.extend(
GeoEntityCacheSensorEntity(coordinator, cache, description)
for cache in status.tracked_caches
for description in CACHE_SENSORS
)
async_add_entities(entities)
# Base class for a cache entity.
# Sets the device, ID and translation settings to correctly group the entity to the correct cache device and give it the correct name.
class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity):
"""Base class for cache entities."""
def __init__(
self,
coordinator: GeocachingDataUpdateCoordinator,
cache: GeocachingCache,
key: str,
) -> None:
"""Initialize the Geocaching sensor."""
super().__init__(coordinator, cache)
self._attr_unique_id = f"{cache.reference_code}_{key}"
# The translation key determines the name of the entity as this is the lookup for the `strings.json` file.
self._attr_translation_key = f"cache_{key}"
class GeoEntityCacheSensorEntity(GeoEntityBaseCache, SensorEntity):
"""Representation of a cache sensor."""
entity_description: GeocachingCacheSensorDescription
def __init__(
self,
coordinator: GeocachingDataUpdateCoordinator,
cache: GeocachingCache,
description: GeocachingCacheSensorDescription,
) -> None:
"""Initialize the Geocaching sensor."""
super().__init__(coordinator, cache, description.key)
self.entity_description = description
@property
def native_value(self) -> StateType | datetime.date:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.cache)
class GeocachingProfileSensor(GeocachingBaseEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: GeocachingSensorEntityDescription

View File

@@ -33,11 +33,36 @@
},
"entity": {
"sensor": {
"find_count": { "name": "Total finds" },
"hide_count": { "name": "Total hides" },
"favorite_points": { "name": "Favorite points" },
"souvenir_count": { "name": "Total souvenirs" },
"awarded_favorite_points": { "name": "Awarded favorite points" }
"find_count": {
"name": "Total finds",
"unit_of_measurement": "caches"
},
"hide_count": {
"name": "Total hides",
"unit_of_measurement": "caches"
},
"favorite_points": {
"name": "Favorite points",
"unit_of_measurement": "points"
},
"souvenir_count": {
"name": "Total souvenirs",
"unit_of_measurement": "souvenirs"
},
"awarded_favorite_points": {
"name": "Awarded favorite points",
"unit_of_measurement": "points"
},
"cache_found_date": {
"name": "Found date"
},
"cache_favorite_points": {
"name": "Favorite points",
"unit_of_measurement": "points"
},
"cache_hidden_date": {
"name": "Hidden date"
}
}
}
}

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-genai==1.29.0"]
"requirements": ["google-genai==1.38.0"]
}

View File

@@ -112,11 +112,14 @@ PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
PLACEHOLDER_KEY_FREE_SPACE = "free_space"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
@@ -137,6 +140,24 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
REQUEST_REFRESH_DELAY = 10
HELP_URLS = {
"help_url": "https://www.home-assistant.io/help/",
"community_url": "https://community.home-assistant.io/",
}
EXTRA_PLACEHOLDERS = {
"issue_mount_mount_failed": {
"storage_url": "/config/storage",
},
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
ISSUE_KEY_SYSTEM_FREE_SPACE: {
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
},
ISSUE_KEY_ADDON_PWNED: {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
}
class SupervisorEntityModel(StrEnum):
"""Supervisor entity model."""

View File

@@ -41,17 +41,21 @@ from .const import (
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
ISSUE_KEY_SYSTEM_FREE_SPACE,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_URL,
PLACEHOLDER_KEY_FREE_SPACE,
PLACEHOLDER_KEY_REFERENCE,
REQUEST_REFRESH_DELAY,
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import get_addons_info
from .coordinator import get_addons_info, get_host_info
from .handler import HassIO, get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
@@ -78,6 +82,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
}
_LOGGER = logging.getLogger(__name__)
@@ -241,11 +247,17 @@ class SupervisorIssues:
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
placeholders: dict[str, str] | None = None
if issue.reference:
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
placeholders: dict[str, str] = {}
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
placeholders |= EXTRA_PLACEHOLDERS[issue.key]
if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
if issue.reference:
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
if issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_PWNED,
}:
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
f"/hassio/addon/{issue.reference}"
)
@@ -257,6 +269,19 @@ class SupervisorIssues:
else:
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
host_info = get_host_info(self._hass)
if (
host_info
and "data" in host_info
and "disk_free" in host_info["data"]
):
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
host_info["data"]["disk_free"]
)
else:
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
async_create_issue(
self._hass,
DOMAIN,
@@ -264,7 +289,7 @@ class SupervisorIssues:
is_fixable=bool(issue.suggestions),
severity=IssueSeverity.WARNING,
translation_key=issue.key,
translation_placeholders=placeholders,
translation_placeholders=placeholders or None,
)
self._issues[issue.uuid] = issue

View File

@@ -16,8 +16,10 @@ from homeassistant.data_entry_flow import FlowResult
from . import get_addons_info, get_issues_info
from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_COMPONENTS,
@@ -26,11 +28,6 @@ from .const import (
from .handler import get_supervisor_client
from .issues import Issue, Suggestion
HELP_URLS = {
"help_url": "https://www.home-assistant.io/help/",
"community_url": "https://community.home-assistant.io/",
}
SUGGESTION_CONFIRMATION_REQUIRED = {
"addon_execute_remove",
"system_adopt_data_disk",
@@ -38,14 +35,6 @@ SUGGESTION_CONFIRMATION_REQUIRED = {
}
EXTRA_PLACEHOLDERS = {
"issue_mount_mount_failed": {
"storage_url": "/config/storage",
},
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
}
class SupervisorIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -219,6 +208,7 @@ async def async_create_fix_flow(
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_PWNED,
}:
return AddonIssueRepairFlow(hass, issue_id)

View File

@@ -52,6 +52,10 @@
}
}
},
"issue_addon_pwned": {
"title": "Insecure secrets detected in add-on configuration",
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
},
"issue_mount_mount_failed": {
"title": "Network storage device failed",
"fix_flow": {
@@ -119,6 +123,10 @@
"title": "Disk lifetime exceeding 90%",
"description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data."
},
"issue_system_free_space": {
"title": "Data disk is running low on free space",
"description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space."
},
"unhealthy": {
"title": "Unhealthy system - {reason}",
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more."
@@ -185,7 +193,7 @@
},
"unsupported_docker_version": {
"title": "Unsupported system - Docker version",
"description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this."
"description": "System is unsupported because the Docker version is out of date. For information about the required version and how to fix this, select Learn more."
},
"unsupported_job_conditions": {
"title": "Unsupported system - Protections disabled",
@@ -201,7 +209,7 @@
},
"unsupported_os": {
"title": "Unsupported system - Operating System",
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this."
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. For information about supported operating systems and how to fix this, select Learn more."
},
"unsupported_os_agent": {
"title": "Unsupported system - OS-Agent issues",

View File

@@ -0,0 +1,23 @@
"""Diagnostics support for history_stats."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
registry = er.async_get(hass)
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
return {
"config_entry": config_entry.as_dict(),
"entity": [entity.extended_dict for entity in entities],
}

View File

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

View File

@@ -103,6 +103,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
VERSION = 1
MINOR_VERSION = 1
ZIGBEE_BAUDRATE = 460800
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""

View File

@@ -52,8 +52,16 @@
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -75,6 +83,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
},
"zigbee_installation_type": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
"menu_options": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
}
},
"zigbee_integration": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
"menu_options": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
}
}
},
"error": {
@@ -111,7 +142,15 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -133,6 +172,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
},
"zigbee_installation_type": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
"menu_options": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
}
},
"zigbee_integration": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
"menu_options": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
}
}
},
"abort": {

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from enum import StrEnum
import logging
from typing import Any
@@ -23,6 +24,7 @@ from homeassistant.config_entries import (
ConfigEntryBaseFlow,
ConfigFlow,
ConfigFlowResult,
FlowType,
OptionsFlow,
)
from homeassistant.core import callback
@@ -48,13 +50,31 @@ _LOGGER = logging.getLogger(__name__)
STEP_PICK_FIRMWARE_THREAD = "pick_firmware_thread"
STEP_PICK_FIRMWARE_ZIGBEE = "pick_firmware_zigbee"
STEP_PICK_FIRMWARE_THREAD_MIGRATE = "pick_firmware_thread_migrate"
STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE = "pick_firmware_zigbee_migrate"
class PickedFirmwareType(StrEnum):
"""Firmware types that can be picked."""
THREAD = "thread"
ZIGBEE = "zigbee"
class ZigbeeIntegration(StrEnum):
"""Zigbee integrations that can be picked."""
OTHER = "other"
ZHA = "zha"
class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
_failed_addon_name: str
_failed_addon_reason: str
_picked_firmware_type: PickedFirmwareType
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Instantiate base flow."""
@@ -63,6 +83,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self._probed_firmware_info: FirmwareInfo | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
self._zigbee_integration = ZigbeeIntegration.ZHA
self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None
@@ -105,11 +126,23 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread or Zigbee firmware."""
# Determine if ZHA or Thread are already configured to present migrate options
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN)
return self.async_show_menu(
step_id="pick_firmware",
menu_options=[
STEP_PICK_FIRMWARE_ZIGBEE,
STEP_PICK_FIRMWARE_THREAD,
(
STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE
if zha_entries
else STEP_PICK_FIRMWARE_ZIGBEE
),
(
STEP_PICK_FIRMWARE_THREAD_MIGRATE
if otbr_entries
else STEP_PICK_FIRMWARE_THREAD
),
],
description_placeholders=self._get_translation_placeholders(),
)
@@ -255,6 +288,45 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress_done(next_step_id=next_step_id)
async def _configure_and_start_otbr_addon(self) -> None:
"""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)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": False,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
try:
await otbr_manager.async_set_addon_options(new_addon_config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
) from err
await otbr_manager.async_start_addon_waiting()
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -281,18 +353,86 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
)
async def async_step_pick_firmware_zigbee(
async def async_step_zigbee_installation_type(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
"""Handle the installation type step."""
return self.async_show_menu(
step_id="zigbee_installation_type",
menu_options=[
"zigbee_intent_recommended",
"zigbee_intent_custom",
],
)
async def async_step_zigbee_intent_recommended(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select recommended installation type."""
self._zigbee_integration = ZigbeeIntegration.ZHA
return await self._async_continue_picked_firmware()
async def async_step_zigbee_intent_custom(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select custom installation type."""
return await self.async_step_zigbee_integration()
async def async_step_zigbee_integration(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select Zigbee integration."""
return self.async_show_menu(
step_id="zigbee_integration",
menu_options=[
"zigbee_integration_zha",
"zigbee_integration_other",
],
)
async def async_step_zigbee_integration_zha(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select ZHA integration."""
self._zigbee_integration = ZigbeeIntegration.ZHA
return await self._async_continue_picked_firmware()
async def async_step_zigbee_integration_other(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select other Zigbee integration."""
self._zigbee_integration = ZigbeeIntegration.OTHER
return await self._async_continue_picked_firmware()
async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
"""Continue to the picked firmware step."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
return await self.async_step_install_zigbee_firmware()
if result := await self._ensure_thread_addon_setup():
return result
return await self.async_step_install_thread_firmware()
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
self._picked_firmware_type = PickedFirmwareType.ZIGBEE
return await self.async_step_zigbee_installation_type()
async def async_step_pick_firmware_zigbee_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware. Migration is automatic."""
return await self.async_step_pick_firmware_zigbee()
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -317,42 +457,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Pre-confirm Zigbee setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_zigbee()
return await self.async_step_continue_zigbee()
async def async_step_confirm_zigbee(
async def async_step_continue_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Zigbee setup."""
"""Continue Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if user_input is None:
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
await self.hass.config_entries.flow.async_init(
if self._zigbee_integration == ZigbeeIntegration.OTHER:
return self._async_flow_finished()
result = await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"baudrate": self.ZIGBEE_BAUDRATE,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
return self._continue_zha_flow(result)
return self._async_flow_finished()
@callback
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
"""Continue the ZHA flow."""
raise NotImplementedError
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
"""Ensure the OTBR addon is set up and not running."""
@@ -371,18 +512,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.RUNNING:
# We only fail setup if we have an instance of OTBR running *and* it's
# pointing to different hardware
if addon_info.options["device"] != self._device:
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
# Otherwise, stop the addon before continuing to flash firmware
# Stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
return None
@@ -391,16 +521,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
self._picked_firmware_type = PickedFirmwareType.THREAD
return await self._async_continue_picked_firmware()
if result := await self._ensure_thread_addon_setup():
return result
return await self.async_step_install_thread_firmware()
async def async_step_pick_firmware_thread_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware. Migration is automatic."""
return await self.async_step_pick_firmware_thread()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
@@ -453,43 +581,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
otbr_manager = get_otbr_addon_manager(self.hass)
if not self.addon_start_task:
# 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,)
):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": False,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
try:
await otbr_manager.async_set_addon_options(new_addon_config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
) from err
self.addon_start_task = self.hass.async_create_task(
otbr_manager.async_start_addon_waiting()
self._configure_and_start_otbr_addon()
)
if not self.addon_start_task.done():
@@ -508,7 +601,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = otbr_manager.addon_name
self._failed_addon_reason = "addon_start_failed"
self._failed_addon_reason = (
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
)
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
@@ -572,6 +667,21 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
return await self.async_step_pick_firmware()
@callback
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
"""Continue the ZHA flow."""
next_flow_id = zha_result["flow_id"]
result = self._async_flow_finished()
return (
self.async_create_entry(
title=result["title"] or self._hardware_name,
data=result["data"],
next_flow=(FlowType.CONFIG_FLOW, next_flow_id),
)
| result # update all items with the child result
)
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
@@ -629,3 +739,10 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
)
return await super().async_step_pick_firmware_thread(user_input)
@callback
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
"""Continue the ZHA flow."""
# The options flow cannot return a next_flow yet, so we just finish here.
# The options flow should be changed to a reconfigure flow.
return self._async_flow_finished()

View File

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

View File

@@ -3,11 +3,19 @@
"options": {
"step": {
"pick_firmware": {
"title": "Pick your firmware",
"description": "Let's get started with setting up your {model}. Do you want to use it to set up a Zigbee or Thread network?",
"title": "Pick your protocol",
"description": "You can use your {model} for a Zigbee or Thread network. Please check what type of devices you want to add to Home Assistant. You can always change this later.",
"menu_options": {
"pick_firmware_zigbee": "Zigbee",
"pick_firmware_thread": "Thread"
"pick_firmware_zigbee": "Use as Zigbee adapter",
"pick_firmware_thread": "Use as Thread adapter",
"pick_firmware_zigbee_migrate": "Migrate Zigbee to a new adapter",
"pick_firmware_thread_migrate": "Migrate Thread to a new adapter"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "Most common protocol.",
"pick_firmware_thread": "Often used for Matter over Thread devices.",
"pick_firmware_zigbee_migrate": "This will move your Zigbee network to the new adapter.",
"pick_firmware_thread_migrate": "This will migrate your Thread Border Router to the new adapter."
}
},
"confirm_zigbee": {
@@ -29,6 +37,29 @@
"confirm_otbr": {
"title": "OpenThread Border Router setup complete",
"description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration."
},
"zigbee_installation_type": {
"title": "Set up Zigbee",
"description": "Choose the installation type for the Zigbee adapter.",
"menu_options": {
"zigbee_intent_recommended": "Recommended installation",
"zigbee_intent_custom": "Custom"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "Automatically install and configure Zigbee.",
"zigbee_intent_custom": "Manually install and configure Zigbee, for example with Zigbee2MQTT."
}
},
"zigbee_integration": {
"title": "Select Zigbee method",
"menu_options": {
"zigbee_integration_zha": "Zigbee Home Automation",
"zigbee_integration_other": "Other"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "Lets Home Assistant control a Zigbee network.",
"zigbee_integration_other": "For example if you want to use the adapter with Zigbee2MQTT."
}
}
},
"abort": {

View File

@@ -52,8 +52,16 @@
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -75,6 +83,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
},
"zigbee_installation_type": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
"menu_options": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
}
},
"zigbee_integration": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
"menu_options": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
}
}
},
"error": {
@@ -111,7 +142,15 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -133,6 +172,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
},
"zigbee_installation_type": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
"menu_options": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
}
},
"zigbee_integration": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
"menu_options": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
}
}
},
"abort": {

View File

@@ -92,7 +92,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(

View File

@@ -75,8 +75,16 @@
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
},
"menu_option_descriptions": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -98,6 +106,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
},
"zigbee_installation_type": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
"menu_options": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
},
"menu_option_descriptions": {
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
}
},
"zigbee_integration": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
"menu_options": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
},
"menu_option_descriptions": {
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
}
}
},
"error": {

View File

@@ -337,7 +337,14 @@ class HKDevice:
# We need to explicitly poll characteristics to get fresh sensor readings
# before processing the entity map and creating devices.
# Use poll_all=True since entities haven't registered their characteristics yet.
try:
await self.async_update(poll_all=True)
except ValueError as exc:
_LOGGER.debug(
"Accessory %s responded with unparsable response, first update was skipped: %s",
self.unique_id,
exc,
)
await self.async_process_entity_map()

View File

@@ -36,6 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .const import DOMAIN
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
@@ -66,15 +67,13 @@ def to_percentage(value: float | None) -> float | None:
return value * 100 if value is not None else None
def time_to_datetime(value: int | None) -> datetime | None:
"""Convert seconds to datetime when value is not None."""
return (
utcnow().replace(microsecond=0) - timedelta(seconds=value)
if value is not None
else None
)
def uptime_to_datetime(value: int) -> datetime:
"""Convert seconds to datetime timestamp."""
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
@@ -647,7 +646,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
lambda data: data.system is not None and data.system.uptime_s is not None
),
value_fn=(
lambda data: time_to_datetime(data.system.uptime_s) if data.system else None
lambda data: (
uptime_to_stable_datetime(data.system.uptime_s)
if data.system is not None and data.system.uptime_s is not None
else None
)
),
),
)

View File

@@ -88,7 +88,7 @@
"message": "Honeywell set temperature failed: invalid temperature {temperature}"
},
"temp_failed_range": {
"message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {heat}, Cool Temperature: {cool}"
"message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat temperature: {heat}, Cool temperature: {cool}"
},
"set_hold_failed": {
"message": "Honeywell could not set permanent hold"

View File

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

View File

@@ -6,7 +6,7 @@
"description": "Enter your credentials",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "Main Password (MFA)",
"password": "Main password (MFA)",
"with_family": "With family"
}
},
@@ -26,8 +26,8 @@
}
},
"verification_code": {
"title": "iCloud verification code",
"description": "Please enter the verification code you just received from iCloud",
"title": "Apple Account code",
"description": "Please enter the verification code you just received from Apple",
"data": {
"verification_code": "Verification code"
}
@@ -40,18 +40,18 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"no_device": "None of your devices have \"Find my iPhone\" activated",
"no_device": "None of your devices have \"Find My\" activated",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"services": {
"update": {
"name": "Update",
"description": "Asks for a state update of all devices linked to an iCloud account.",
"description": "Asks for a state update of all devices linked to an Apple Account.",
"fields": {
"account": {
"name": "Account",
"description": "Your iCloud account username (email) or account name."
"description": "Your Apple Account username (email)."
}
}
},

View File

@@ -105,6 +105,20 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
raise HomeAssistantError("Unable to get image")
async def async_get_image(
hass: HomeAssistant,
entity_id: str,
timeout: int = 10,
) -> Image:
"""Fetch an image from an image entity."""
component = hass.data[DATA_COMPONENT]
if (image := component.get_entity(entity_id)) is None:
raise HomeAssistantError(f"Image entity {entity_id} not found")
return await _async_get_image(image, timeout)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the image component."""
component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity](

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["imeon_inverter_api==0.3.16"],
"requirements": ["imeon_inverter_api==0.4.0"],
"ssdp": [
{
"manufacturer": "IMEON",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["imgw_pib==1.5.4"]
"requirements": ["imgw_pib==1.5.6"]
}

View File

@@ -42,7 +42,7 @@
"characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"provision_successful": "The device has successfully connected to the Wi-Fi network.",
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.",
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease finish the setup by following the [setup instructions]({url}).",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}

View File

@@ -118,27 +118,31 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
"section_binary_control": KNXSectionFlat(),
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False),
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"),
vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(),
"section_stop_control": KNXSectionFlat(),
vol.Optional(CONF_GA_STOP): GASelector(state=False),
vol.Optional(CONF_GA_STEP): GASelector(state=False),
vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"),
"section_position_control": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False),
vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False),
vol.Optional(CONF_GA_POSITION_SET): GASelector(
state=False, valid_dpt="5.001"
),
vol.Optional(CONF_GA_POSITION_STATE): GASelector(
write=False, valid_dpt="5.001"
),
vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(),
"section_tilt_control": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_ANGLE): GASelector(),
vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"),
vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(),
"section_travel_time": KNXSectionFlat(),
vol.Optional(
vol.Required(
CoverConf.TRAVELLING_TIME_UP, default=25
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=1000, step=0.1, unit_of_measurement="s"
)
),
vol.Optional(
vol.Required(
CoverConf.TRAVELLING_TIME_DOWN, default=25
): selector.NumberSelector(
selector.NumberSelectorConfig(
@@ -310,7 +314,7 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
SWITCH_KNX_SCHEMA = vol.Schema(
{
"section_switch": KNXSectionFlat(),
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True),
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),

View File

@@ -2,7 +2,9 @@
import asyncio
import logging
import uuid
from aiohttp import ClientSession
from packaging import version
from pylamarzocco import (
LaMarzoccoBluetoothClient,
@@ -11,6 +13,7 @@ from pylamarzocco import (
)
from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.util import InstallationKey, generate_installation_key
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.const import (
@@ -19,13 +22,14 @@ from homeassistant.const import (
CONF_TOKEN,
CONF_USERNAME,
Platform,
__version__,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_USE_BLUETOOTH, DOMAIN
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoConfigEntry,
LaMarzoccoConfigUpdateCoordinator,
@@ -60,7 +64,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
client=async_create_clientsession(hass),
installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]),
client=create_client_session(hass),
)
try:
@@ -166,45 +171,50 @@ async def async_migrate_entry(
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
) -> bool:
"""Migrate config entry."""
if entry.version > 3:
if entry.version > 4:
# guard against downgrade from a future version
return False
if entry.version == 1:
if entry.version in (1, 2):
_LOGGER.error(
"Migration from version 1 is no longer supported, please remove and re-add the integration"
"Migration from version 1 or 2 is no longer supported, please remove and re-add the integration"
)
return False
if entry.version == 2:
if entry.version == 3:
installation_key = generate_installation_key(str(uuid.uuid4()).lower())
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
installation_key=installation_key,
client=create_client_session(hass),
)
try:
things = await cloud_client.list_things()
await cloud_client.async_register_client()
except (AuthFail, RequestNotSuccessful) as exc:
_LOGGER.error("Migration failed with error %s", exc)
return False
v3_data = {
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
CONF_TOKEN: next(
(
thing.ble_auth_token
for thing in things
if thing.serial_number == entry.unique_id
),
None,
),
}
if CONF_MAC in entry.data:
v3_data[CONF_MAC] = entry.data[CONF_MAC]
hass.config_entries.async_update_entry(
entry,
data=v3_data,
version=3,
data={
**entry.data,
CONF_INSTALLATION_KEY: installation_key.to_json(),
},
version=4,
)
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
_LOGGER.debug("Migrated La Marzocco config entry to version 4")
return True
def create_client_session(hass: HomeAssistant) -> ClientSession:
"""Create a ClientSession with La Marzocco specific headers."""
return async_create_clientsession(
hass,
headers={
"X-Client": "HOME_ASSISTANT",
"X-Client-Build": __version__,
},
)

View File

@@ -5,11 +5,13 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import uuid
from aiohttp import ClientSession
from pylamarzocco import LaMarzoccoCloudClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import Thing
from pylamarzocco.util import InstallationKey, generate_installation_key
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -33,7 +35,6 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -45,7 +46,8 @@ from homeassistant.helpers.selector import (
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_USE_BLUETOOTH, DOMAIN
from . import create_client_session
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
@@ -57,9 +59,10 @@ _LOGGER = logging.getLogger(__name__)
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for La Marzocco."""
VERSION = 3
VERSION = 4
_client: ClientSession
_installation_key: InstallationKey
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -83,13 +86,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
}
self._client = async_create_clientsession(self.hass)
self._client = create_client_session(self.hass)
self._installation_key = generate_installation_key(
str(uuid.uuid4()).lower()
)
cloud_client = LaMarzoccoCloudClient(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
client=self._client,
installation_key=self._installation_key,
)
try:
await cloud_client.async_register_client()
things = await cloud_client.list_things()
except AuthFail:
_LOGGER.debug("Server rejected login credentials")
@@ -184,6 +192,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
title=selected_device.name,
data={
**self._config,
CONF_INSTALLATION_KEY: self._installation_key.to_json(),
CONF_TOKEN: self._things[serial_number].ble_auth_token,
},
)

View File

@@ -5,3 +5,4 @@ from typing import Final
DOMAIN: Final = "lamarzocco"
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
CONF_INSTALLATION_KEY: Final = "installation_key"

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.11"]
"requirements": ["pylamarzocco==2.1.0"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.8.10", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"]
}

View File

@@ -209,5 +209,11 @@
}
}
}
},
"issues": {
"deprecated_entity": {
"title": "{name} is deprecated",
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
}
}
}

View File

@@ -6,13 +6,24 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Generic
from pylitterbot import FeederRobot, LitterRobot, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -26,6 +37,15 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti
value_fn: Callable[[_WhiskerEntityT], bool]
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION = RobotSwitchEntityDescription[
LitterRobot | FeederRobot
](
key="night_light_mode_enabled",
translation_key="night_light_mode",
set_fn=lambda robot, value: robot.set_night_light(value),
value_fn=lambda robot: robot.night_light_mode_enabled,
)
SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
FeederRobot: (
RobotSwitchEntityDescription[FeederRobot](
@@ -34,14 +54,10 @@ SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
set_fn=lambda robot, value: robot.set_gravity_mode(value),
value_fn=lambda robot: robot.gravity_mode_enabled,
),
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,
),
LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,),
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
key="night_light_mode_enabled",
translation_key="night_light_mode",
set_fn=lambda robot, value: robot.set_night_light(value),
value_fn=lambda robot: robot.night_light_mode_enabled,
),
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
key="panel_lock_enabled",
translation_key="panel_lockout",
@@ -59,13 +75,54 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot switches using config entry."""
coordinator = entry.runtime_data
async_add_entities(
entities = [
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in SWITCH_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
]
ent_reg = er.async_get(hass)
def add_deprecated_entity(
robot: LitterRobot4,
description: RobotSwitchEntityDescription,
entity_cls: type[RobotSwitchEntity],
) -> None:
"""Add deprecated entities."""
unique_id = f"{robot.serial}-{description.key}"
if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
entity_entry = ent_reg.async_get(entity_id)
if entity_entry and entity_entry.disabled:
ent_reg.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"deprecated_entity_{unique_id}",
)
elif entity_entry:
entities.append(entity_cls(robot, coordinator, description))
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{unique_id}",
breaks_in_ha_version="2026.4.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
"entity": entity_id,
},
)
for robot in coordinator.account.get_robots(LitterRobot4):
add_deprecated_entity(
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
)
async_add_entities(entities)
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):

View File

@@ -8,7 +8,7 @@ import logging
import ssl
from typing import Any, cast
from pylutron_caseta import BUTTON_STATUS_PRESSED
from pylutron_caseta import BUTTON_STATUS_MULTITAP, BUTTON_STATUS_PRESSED
from pylutron_caseta.smartbridge import Smartbridge
import voluptuous as vol
@@ -25,6 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
ACTION_MULTITAP,
ACTION_PRESS,
ACTION_RELEASE,
ATTR_ACTION,
@@ -448,6 +449,8 @@ def _async_subscribe_keypad_events(
if event_type == BUTTON_STATUS_PRESSED:
action = ACTION_PRESS
elif event_type == BUTTON_STATUS_MULTITAP:
action = ACTION_MULTITAP
else:
action = ACTION_RELEASE

View File

@@ -29,6 +29,7 @@ ATTR_DEVICE_NAME = "device_name"
ATTR_AREA_NAME = "area_name"
ATTR_ACTION = "action"
ACTION_MULTITAP = "multi_tap"
ACTION_PRESS = "press"
ACTION_RELEASE = "release"

View File

@@ -21,6 +21,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
ACTION_MULTITAP,
ACTION_PRESS,
ACTION_RELEASE,
ATTR_ACTION,
@@ -39,7 +40,7 @@ def _reverse_dict(forward_dict: dict) -> dict:
return {v: k for k, v in forward_dict.items()}
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE]
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_MULTITAP, ACTION_RELEASE]
LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{

View File

@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.24.0"],
"requirements": ["pylutron-caseta==0.25.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",

View File

@@ -296,24 +296,22 @@ class MatterClimate(MatterEntity, ClimateEntity):
if running_state_value := self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ThermostatRunningState
):
match running_state_value:
case (
if running_state_value & (
ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
):
self._attr_hvac_action = HVACAction.HEATING
case (
elif running_state_value & (
ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
):
self._attr_hvac_action = HVACAction.COOLING
case (
elif running_state_value & (
ThermostatRunningState.Fan
| ThermostatRunningState.FanStage2
| ThermostatRunningState.FanStage3
):
self._attr_hvac_action = HVACAction.FAN
case _:
else:
self._attr_hvac_action = HVACAction.OFF
# update target temperature high/low
supports_range = (
self._attr_supported_features

View File

@@ -46,14 +46,7 @@ class UploadedFile(Protocol):
async def async_get_media_source(hass: HomeAssistant) -> LocalSource:
"""Set up local media source."""
return LocalSource(
hass,
DOMAIN,
"My media",
hass.config.media_dirs,
not hass.config.media_dirs_set_default,
"/media",
)
return LocalSource(hass, DOMAIN, "My media", hass.config.media_dirs, "/media")
class LocalSource(MediaSource):
@@ -65,7 +58,6 @@ class LocalSource(MediaSource):
domain: str,
name: str,
media_dirs: dict[str, str],
error_missing_media_dir: bool,
url_prefix: str,
) -> None:
"""Initialize local source."""
@@ -73,7 +65,6 @@ class LocalSource(MediaSource):
self.hass = hass
self.name = name
self.media_dirs = media_dirs
self.error_missing_media_dir = error_missing_media_dir
self.url_prefix = url_prefix
@callback
@@ -219,23 +210,9 @@ class LocalSource(MediaSource):
full_path = Path(self.media_dirs[source_dir_id], location)
if not full_path.exists():
if location != "":
raise BrowseError("Path does not exist.")
if self.error_missing_media_dir:
if location == "":
raise BrowseError("Media directory does not exist.")
# If a media dir does not exist, return an empty folder
# It will be created when uploading files
return BrowseMediaSource(
domain=self.domain,
identifier=source_dir_id,
media_class=MediaClass.DIRECTORY,
media_content_type=None,
title=self.name,
can_play=False,
can_expand=True,
)
raise BrowseError("Path does not exist.")
if not full_path.is_dir():
raise BrowseError("Path is not a directory.")

View File

@@ -167,178 +167,263 @@ PROCESS_ACTIONS = {
"stop_supercooling": MieleActions.STOP_SUPERCOOL,
}
STATE_PROGRAM_PHASE_WASHING_MACHINE = {
0: "not_running", # Returned by the API when the machine is switched off entirely.
256: "not_running",
257: "pre_wash",
258: "soak",
259: "pre_wash",
260: "main_wash",
261: "rinse",
262: "rinse_hold",
263: "cleaning",
264: "cooling_down",
265: "drain",
266: "spin",
267: "anti_crease",
268: "finished",
269: "venting",
270: "starch_stop",
271: "freshen_up_and_moisten",
272: "steam_smoothing",
279: "hygiene",
280: "drying",
285: "disinfecting",
295: "steam_smoothing",
65535: "not_running", # Seems to be default for some devices.
}
STATE_PROGRAM_PHASE_TUMBLE_DRYER = {
0: "not_running",
512: "not_running",
513: "program_running",
514: "drying",
515: "machine_iron",
516: "hand_iron_2",
517: "normal",
518: "normal_plus",
519: "cooling_down",
520: "hand_iron_1",
521: "anti_crease",
522: "finished",
523: "extra_dry",
524: "hand_iron",
526: "moisten",
527: "thermo_spin",
528: "timed_drying",
529: "warm_air",
530: "steam_smoothing",
531: "comfort_cooling",
532: "rinse_out_lint",
533: "rinses",
535: "not_running",
534: "smoothing",
536: "not_running",
537: "not_running",
538: "slightly_dry",
539: "safety_cooling",
65535: "not_running",
}
class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
"""Program phase codes for washing machines."""
STATE_PROGRAM_PHASE_DISHWASHER = {
1792: "not_running",
1793: "reactivating",
1794: "pre_dishwash",
1795: "main_dishwash",
1796: "rinse",
1797: "interim_rinse",
1798: "final_rinse",
1799: "drying",
1800: "finished",
1801: "pre_dishwash",
65535: "not_running",
}
not_running = 0, 256, 65535
pre_wash = 257, 259
soak = 258
main_wash = 260
rinse = 261
rinse_hold = 262
cleaning = 263
cooling_down = 264
drain = 265
spin = 266
anti_crease = 267
finished = 268
venting = 269
starch_stop = 270
freshen_up_and_moisten = 271
steam_smoothing = 272, 295
hygiene = 279
drying = 280
disinfecting = 285
STATE_PROGRAM_PHASE_OVEN = {
0: "not_running",
3073: "heating_up",
3074: "process_running",
3078: "process_finished",
3084: "energy_save",
65535: "not_running",
}
STATE_PROGRAM_PHASE_WARMING_DRAWER = {
0: "not_running",
3073: "heating_up",
3075: "door_open",
3094: "keeping_warm",
3088: "cooling_down",
65535: "not_running",
}
STATE_PROGRAM_PHASE_MICROWAVE = {
0: "not_running",
3329: "heating",
3330: "process_running",
3334: "process_finished",
3340: "energy_save",
65535: "not_running",
}
STATE_PROGRAM_PHASE_COFFEE_SYSTEM = {
# Coffee system
3073: "heating_up",
4352: "not_running",
4353: "espresso",
4355: "milk_foam",
4361: "dispensing",
4369: "pre_brewing",
4377: "grinding",
4401: "2nd_grinding",
4354: "hot_milk",
4393: "2nd_pre_brewing",
4385: "2nd_espresso",
4404: "dispensing",
4405: "rinse",
65535: "not_running",
}
STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER = {
0: "not_running",
5889: "vacuum_cleaning",
5890: "returning",
5891: "vacuum_cleaning_paused",
5892: "going_to_target_area",
5893: "wheel_lifted", # F1
5894: "dirty_sensors", # F2
5895: "dust_box_missing", # F3
5896: "blocked_drive_wheels", # F4
5897: "blocked_brushes", # F5
5898: "motor_overload", # F6
5899: "internal_fault", # F7
5900: "blocked_front_wheel", # F8
5903: "docked",
5904: "docked",
5910: "remote_controlled",
65535: "not_running",
}
STATE_PROGRAM_PHASE_STEAM_OVEN = {
0: "not_running",
3863: "steam_reduction",
7938: "process_running",
7939: "waiting_for_start",
7940: "heating_up_phase",
7942: "process_finished",
65535: "not_running",
}
STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = {
MieleAppliance.WASHING_MACHINE: STATE_PROGRAM_PHASE_WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE,
MieleAppliance.TUMBLE_DRYER: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
MieleAppliance.DRYER_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
MieleAppliance.WASHER_DRYER: STATE_PROGRAM_PHASE_WASHING_MACHINE
| STATE_PROGRAM_PHASE_TUMBLE_DRYER,
MieleAppliance.DISHWASHER: STATE_PROGRAM_PHASE_DISHWASHER,
MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER,
MieleAppliance.DISHWASHER_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER,
MieleAppliance.OVEN: STATE_PROGRAM_PHASE_OVEN,
MieleAppliance.OVEN_MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE,
MieleAppliance.STEAM_OVEN: STATE_PROGRAM_PHASE_STEAM_OVEN,
MieleAppliance.STEAM_OVEN_COMBI: STATE_PROGRAM_PHASE_OVEN
| STATE_PROGRAM_PHASE_STEAM_OVEN,
MieleAppliance.STEAM_OVEN_MICRO: STATE_PROGRAM_PHASE_MICROWAVE
| STATE_PROGRAM_PHASE_STEAM_OVEN,
MieleAppliance.STEAM_OVEN_MK2: STATE_PROGRAM_PHASE_OVEN
| STATE_PROGRAM_PHASE_STEAM_OVEN,
MieleAppliance.DIALOG_OVEN: STATE_PROGRAM_PHASE_OVEN,
MieleAppliance.MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE,
MieleAppliance.COFFEE_SYSTEM: STATE_PROGRAM_PHASE_COFFEE_SYSTEM,
MieleAppliance.ROBOT_VACUUM_CLEANER: STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER,
MieleAppliance.DISH_WARMER: STATE_PROGRAM_PHASE_WARMING_DRAWER,
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
"""Program phase codes for tumble dryers."""
not_running = 0, 512, 535, 536, 537, 65535
program_running = 513
drying = 514
machine_iron = 515
hand_iron_2 = 516
normal = 517
normal_plus = 518
cooling_down = 519
hand_iron_1 = 520
anti_crease = 521
finished = 522
extra_dry = 523
hand_iron = 524
moisten = 526
thermo_spin = 527
timed_drying = 528
warm_air = 529
steam_smoothing = 530
comfort_cooling = 531
rinse_out_lint = 532
rinses = 533
smoothing = 534
slightly_dry = 538
safety_cooling = 539
class ProgramPhaseWasherDryer(MieleEnum, missing_to_none=True):
"""Program phase codes for washer/dryer machines."""
not_running = 0, 256, 512, 535, 536, 537, 65535
pre_wash = 257, 259
soak = 258
main_wash = 260
rinse = 261
rinse_hold = 262
cleaning = 263
cooling_down = 264, 519
drain = 265
spin = 266
anti_crease = 267, 521
finished = 268, 522
venting = 269
starch_stop = 270
freshen_up_and_moisten = 271
steam_smoothing = 272, 295, 530
hygiene = 279
drying = 280, 514
disinfecting = 285
program_running = 513
machine_iron = 515
hand_iron_2 = 516
normal = 517
normal_plus = 518
hand_iron_1 = 520
extra_dry = 523
hand_iron = 524
moisten = 526
thermo_spin = 527
timed_drying = 528
warm_air = 529
comfort_cooling = 531
rinse_out_lint = 532
rinses = 533
smoothing = 534
slightly_dry = 538
safety_cooling = 539
class ProgramPhaseDishwasher(MieleEnum, missing_to_none=True):
"""Program phase codes for dishwashers."""
not_running = 0, 1792, 65535
reactivating = 1793
pre_dishwash = 1794, 1801
main_dishwash = 1795
rinse = 1796
interim_rinse = 1797
final_rinse = 1798
drying = 1799
finished = 1800
class ProgramPhaseOven(MieleEnum, missing_to_none=True):
"""Program phase codes for ovens."""
not_running = 0, 65535
heating_up = 3073
process_running = 3074
process_finished = 3078
energy_save = 3084
pre_heating = 3099
class ProgramPhaseWarmingDrawer(MieleEnum, missing_to_none=True):
"""Program phase codes for warming drawers."""
not_running = 0, 65535
heating_up = 3073
door_open = 3075
keeping_warm = 3094
cooling_down = 3088
class ProgramPhaseMicrowave(MieleEnum, missing_to_none=True):
"""Program phase for microwave units."""
not_running = 0, 65535
heating = 3329
process_running = 3330
process_finished = 3334
energy_save = 3340
class ProgramPhaseCoffeeSystem(MieleEnum, missing_to_none=True):
"""Program phase codes for coffee systems."""
not_running = 0, 4352, 65535
heating_up = 3073
espresso = 4353
hot_milk = 4354
milk_foam = 4355
dispensing = 4361, 4404
pre_brewing = 4369
grinding = 4377
second_espresso = 4385
second_pre_brewing = 4393
second_grinding = 4401
rinse = 4405
class ProgramPhaseRobotVacuumCleaner(MieleEnum, missing_to_none=True):
"""Program phase codes for robot vacuum cleaner."""
not_running = 0, 65535
vacuum_cleaning = 5889
returning = 5890
vacuum_cleaning_paused = 5891
going_to_target_area = 5892
wheel_lifted = 5893 # F1
dirty_sensors = 5894 # F2
dust_box_missing = 5895 # F3
blocked_drive_wheels = 5896 # F4
blocked_brushes = 5897 # F5
motor_overload = 5898 # F6
internal_fault = 5899 # F7
blocked_front_wheel = 5900 # F8
docked = 5903, 5904
remote_controlled = 5910
class ProgramPhaseMicrowaveOvenCombo(MieleEnum, missing_to_none=True):
"""Program phase codes for microwave oven combo."""
not_running = 0, 65535
steam_reduction = 3863
process_running = 7938
waiting_for_start = 7939
heating_up_phase = 7940
process_finished = 7942
class ProgramPhaseSteamOven(MieleEnum, missing_to_none=True):
"""Program phase codes for steam ovens."""
not_running = 0, 65535
steam_reduction = 3863
process_running = 7938
waiting_for_start = 7939
heating_up_phase = 7940
process_finished = 7942
class ProgramPhaseSteamOvenCombi(MieleEnum, missing_to_none=True):
"""Program phase codes for steam oven combi."""
not_running = 0, 65535
heating_up = 3073
process_running = 3074, 7938
process_finished = 3078, 7942
energy_save = 3084
pre_heating = 3099
steam_reduction = 3863
waiting_for_start = 7939
heating_up_phase = 7940
class ProgramPhaseSteamOvenMicro(MieleEnum, missing_to_none=True):
"""Program phase codes for steam oven micro."""
not_running = 0, 65535
heating = 3329
process_running = 3330, 7938, 7942
process_finished = 3334
energy_save = 3340
steam_reduction = 3863
waiting_for_start = 7939
heating_up_phase = 7940
PROGRAM_PHASE: dict[int, type[MieleEnum]] = {
MieleAppliance.WASHING_MACHINE: ProgramPhaseWashingMachine,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: ProgramPhaseWashingMachine,
MieleAppliance.WASHING_MACHINE_PROFESSIONAL: ProgramPhaseWashingMachine,
MieleAppliance.TUMBLE_DRYER: ProgramPhaseTumbleDryer,
MieleAppliance.DRYER_PROFESSIONAL: ProgramPhaseTumbleDryer,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: ProgramPhaseTumbleDryer,
MieleAppliance.WASHER_DRYER: ProgramPhaseWasherDryer,
MieleAppliance.DISHWASHER: ProgramPhaseDishwasher,
MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: ProgramPhaseDishwasher,
MieleAppliance.DISHWASHER_PROFESSIONAL: ProgramPhaseDishwasher,
MieleAppliance.OVEN: ProgramPhaseOven,
MieleAppliance.OVEN_MICROWAVE: ProgramPhaseMicrowaveOvenCombo,
MieleAppliance.STEAM_OVEN: ProgramPhaseSteamOven,
MieleAppliance.STEAM_OVEN_COMBI: ProgramPhaseSteamOvenCombi,
MieleAppliance.STEAM_OVEN_MK2: ProgramPhaseSteamOvenCombi,
MieleAppliance.STEAM_OVEN_MICRO: ProgramPhaseSteamOvenMicro,
MieleAppliance.DIALOG_OVEN: ProgramPhaseOven,
MieleAppliance.MICROWAVE: ProgramPhaseMicrowave,
MieleAppliance.COFFEE_SYSTEM: ProgramPhaseCoffeeSystem,
MieleAppliance.ROBOT_VACUUM_CLEANER: ProgramPhaseRobotVacuumCleaner,
MieleAppliance.DISH_WARMER: ProgramPhaseWarmingDrawer,
}
class StateProgramType(MieleEnum):
class StateProgramType(MieleEnum, missing_to_none=True):
"""Defines program types."""
normal_operation_mode = 0
@@ -346,10 +431,9 @@ class StateProgramType(MieleEnum):
automatic_program = 2
cleaning_care_program = 3
maintenance_program = 4
missing2none = -9999
class StateDryingStep(MieleEnum):
class StateDryingStep(MieleEnum, missing_to_none=True):
"""Defines drying steps."""
extra_dry = 0
@@ -360,7 +444,6 @@ class StateDryingStep(MieleEnum):
hand_iron_2 = 5
machine_iron = 6
smoothing = 7
missing2none = -9999
WASHING_MACHINE_PROGRAM_ID: dict[int, str] = {
@@ -1314,7 +1397,7 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = {
}
class PlatePowerStep(MieleEnum):
class PlatePowerStep(MieleEnum, missing_to_none=True):
"""Plate power settings."""
plate_step_0 = 0
@@ -1339,4 +1422,3 @@ class PlatePowerStep(MieleEnum):
plate_step_18 = 18
plate_step_boost = 117, 118, 218
plate_step_boost_2 = 217
missing2none = -9999

View File

@@ -8,7 +8,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "platinum",
"requirements": ["pymiele==0.5.4"],
"requirements": ["pymiele==0.5.5"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}

View File

@@ -35,8 +35,8 @@ from .const import (
COFFEE_SYSTEM_PROFILE,
DISABLED_TEMP_ENTITIES,
DOMAIN,
PROGRAM_PHASE,
STATE_PROGRAM_ID,
STATE_PROGRAM_PHASE,
STATE_STATUS_TAGS,
MieleAppliance,
PlatePowerStep,
@@ -851,29 +851,36 @@ class MieleStatusSensor(MieleSensor):
return True
# Some phases have names that are not valid python identifiers, so we need to translate
# them in order to avoid breaking changes
PROGRAM_PHASE_TRANSLATION = {
"second_espresso": "2nd_espresso",
"second_grinding": "2nd_grinding",
"second_pre_brewing": "2nd_pre_brewing",
}
class MielePhaseSensor(MieleSensor):
"""Representation of the program phase sensor."""
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
ret_val = STATE_PROGRAM_PHASE.get(self.device.device_type, {}).get(
"""Return the state of the phase sensor."""
program_phase = PROGRAM_PHASE[self.device.device_type](
self.device.state_program_phase
).name
return (
PROGRAM_PHASE_TRANSLATION.get(program_phase, program_phase)
if program_phase is not None
else None
)
if ret_val is None:
_LOGGER.debug(
"Unknown program phase: %s on device type: %s",
self.device.state_program_phase,
self.device.device_type,
)
return ret_val
@property
def options(self) -> list[str]:
"""Return the options list for the actual device type."""
return sorted(
set(STATE_PROGRAM_PHASE.get(self.device.device_type, {}).values())
)
phases = PROGRAM_PHASE[self.device.device_type].keys()
return sorted([PROGRAM_PHASE_TRANSLATION.get(phase, phase) for phase in phases])
class MieleProgramIdSensor(MieleSensor):

View File

@@ -291,6 +291,7 @@
"not_running": "Not running",
"pre_brewing": "Pre-brewing",
"pre_dishwash": "Pre-cleaning",
"pre_heating": "Pre-heating",
"pre_wash": "Pre-wash",
"process_finished": "Process finished",
"process_running": "Process running",

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