Compare commits

...

196 Commits

Author SHA1 Message Date
Franck Nijhof
8fc3fa51a8 Bump version to 2025.7.0b9 2025-07-02 13:30:51 +00:00
c0ffeeca7
4eb688b560 Z-Wave JS: rename controller to adapter according to term decision (#147955)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-02 13:30:31 +00:00
Simone Chemelli
9472ff5d36 Bump aioamazondevices to 3.2.2 (#147953) 2025-07-02 13:30:29 +00:00
Bram Kragten
12e8b81ec7 Update frontend to 20250702.0 (#147952) 2025-07-02 13:30:28 +00:00
Paulus Schoutsen
ec5e543c09 Ollama: Migrate pick model to subentry (#147944) 2025-07-02 13:30:27 +00:00
Paulus Schoutsen
116c745872 Split Ollama entity (#147769) 2025-07-02 13:30:26 +00:00
Robert Resch
1fdf152292 Bump deebot-client to 13.5.0 (#147938) 2025-07-02 13:27:47 +00:00
G Johansson
b816f1a408 Handle additional errors in Nord Pool (#147937) 2025-07-02 13:27:46 +00:00
John Hess
eb351e6505 Bump thermopro-ble to 0.13.1 (#147924) 2025-07-02 13:27:45 +00:00
Maciej Bieniek
2f27d55495 Open repair issue when outbound WebSocket is enabled for Shelly non-sleeping RPC device (#147901) 2025-07-02 13:26:03 +00:00
Space
fa1bed1849 Skip processing request body for HTTP HEAD requests (#147899)
* Skip processing request body for HTTP HEAD requests

* Use aiohttp's must_be_empty_body() to check whether ingress requests should be streamed

* Only call must_be_empty_body() once per request

* Fix incorrect use of walrus operator
2025-07-02 13:26:01 +00:00
Raphael Hehl
b8c19f23f3 UnifiProtect Change log level from debug to error for connection exceptions in ProtectFlowHandler (#147730) 2025-07-02 13:26:00 +00:00
Erwin Douna
b677ce6c90 SMA add DHCP strictness (#145753)
* Add DHCP strictness (needs beta check)

* Update to check on CONF_MAC

* Update to check on CONF_HOST

* Update hostname

* Polish it a bit

* Update to CONF_HOST, again

* Add split

* Add CONF_MAC add upon detection

* epenet feedback

* epenet round II
2025-07-02 13:25:59 +00:00
Franck Nijhof
0e6bbb30c1 Bump version to 2025.7.0b8 2025-07-02 06:04:14 +00:00
J. Nick Koston
fdba791f18 Bump bluetooth-data-tools to 1.28.2 (#147920) 2025-07-02 06:03:56 +00:00
Ivan Lopez Hernandez
d4dec6c7a9 Swap the Models label for the model name not it's display name, (#147918)
Swap display name for name.
2025-07-02 06:03:55 +00:00
Simone Chemelli
f838e85a79 Manager wrong country selection in Alexa Devices (#147914)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-07-02 06:03:54 +00:00
Simone Chemelli
04ae966544 Bump aioamazondevices to 3.2.1 (#147912) 2025-07-02 06:03:53 +00:00
Franck Nijhof
b2c393db72 Bump version to 2025.7.0b7 2025-07-01 20:11:01 +00:00
Marcel van der Veldt
3ed440a3af Bump Music Assistant Client to 1.2.3 (#147885) 2025-07-01 20:08:45 +00:00
Jamin
01e7efc7b4 Bump VoIP utils to 0.3.3 (#147880) 2025-07-01 20:08:44 +00:00
avee87
60a930554a Fix station name sensor for metoffice (#145500) 2025-07-01 20:08:43 +00:00
Franck Nijhof
c707bf6264 Bump version to 2025.7.0b6 2025-07-01 14:26:59 +00:00
Paul Bottein
3548ab70fd Update frontend to 20250701.0 (#147879) 2025-07-01 14:10:30 +00:00
Erik Montnemery
e272ab1885 Initialize EsphomeEntity._has_state (#147877) 2025-07-01 14:10:29 +00:00
Erik Montnemery
d5d1b620d0 Correct openai conversation config entry migration (#147859) 2025-07-01 14:10:28 +00:00
Erik Montnemery
8b2f4f0f86 Correct ollama config entry migration (#147858) 2025-07-01 14:10:26 +00:00
Erik Montnemery
725269ecda Correct anthropic config entry migration (#147857) 2025-07-01 14:10:25 +00:00
Erik Montnemery
c42fc818bf Correct Google generative AI config entry migration (#147856) 2025-07-01 14:10:23 +00:00
Jesse Hills
5554e38171 Implement suggested_display_precision for ESPHome (#147849) 2025-07-01 14:10:22 +00:00
Jan Bouwhuis
b25acfe823 Fix invalid configuration of MQTT device QoS option in subentry flow (#147837) 2025-07-01 14:10:21 +00:00
micha91
ff25948e37 fix: Create new aiohttp session with DummyCookieJar (#147827) 2025-07-01 14:10:19 +00:00
Maciej Bieniek
f85fc7173f Bump Nettigo Air Monitor backend library to version 5.0.0 (#147812) 2025-07-01 14:10:18 +00:00
Bob Laz
748cc6386d fix state_class for water used today sensor (#147787) 2025-07-01 14:10:17 +00:00
Manu
47b232db49 Add more mac address prefixes for discovery to PlayStation Network (#147739) 2025-07-01 14:10:15 +00:00
hanwg
c61935fc41 Include chat ID in Telegram bot subentry title (#147643) 2025-07-01 14:10:14 +00:00
Jan-Philipp Benecke
414318f3fb Catch access denied errors in webdav and display proper message (#147093) 2025-07-01 14:10:12 +00:00
Paul Bottein
08985d783f Fix Meteo france Ciel clair condition mapping (#146965)
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-07-01 14:10:11 +00:00
Thomas55555
e4bcde7d20 Fix wrong state in Husqvarna Automower (#146075) 2025-07-01 14:10:10 +00:00
Franck Nijhof
db04c77e62 Bump version to 2025.7.0b5 2025-06-30 19:39:34 +00:00
puddly
e8204e5f8e Await firmware installation task when flashing ZBT-1/Yellow firmware (#147824) 2025-06-30 19:39:03 +00:00
starkillerOG
66cf9c4ed5 Bump reolink_aio to 0.14.2 (#147797) 2025-06-30 19:39:02 +00:00
mkmer
1f6d28dcbf Honeywell: Don't use shared session (#147772) 2025-06-30 19:39:02 +00:00
Paulus Schoutsen
328e838351 Use media selector for Assist Satellite actions (#147767)
Co-authored-by: Michael Hansen <mike@rhasspy.org>
2025-06-30 19:39:01 +00:00
cdnninja
62a1c8af11 Fix Vesync set_percentage error (#147751) 2025-06-30 19:39:00 +00:00
tronikos
b50e599517 Move the async_reload on updates in async_setup_entry in Google Generative AI (#147748)
Move the async_reload on updates in async_setup_entry
2025-06-30 19:38:59 +00:00
Manu
3c7c9176d2 Fix sensor displaying unknown when getting readings from heat meters in ista EcoTrend (#147741) 2025-06-30 19:37:54 +00:00
J. Nick Koston
c771f5fe1e Preserve httpx boolean behavior in REST integration after aiohttp conversion (#147738) 2025-06-30 19:35:31 +00:00
hanwg
6dc464ad73 Fix Telegram bot proxy URL not initialized when creating a new bot (#147707) 2025-06-30 19:35:30 +00:00
Marc Hörsken
ae48e3716e Update pywmspro to 0.3.0 to wait for short-lived actions (#147679)
Replace action delays with detailed action responses.
2025-06-30 19:35:29 +00:00
Hessel
1543726095 Wallbox Integration, Reduce API impact by limiting the amount of API calls made (#147618) 2025-06-30 19:35:27 +00:00
Evan Severson
adbace95c3 Fixed pushbullet handling of fields longer than 255 characters (#146993) 2025-06-30 19:35:26 +00:00
Shay Levy
578b43cf61 Bump aioshelly to 13.7.1 (#146221)
* Bump aioshelly to 13.8.0

* Change version to 13.7.1
2025-06-30 19:35:25 +00:00
mvn23
a8b5d1511d Populate hvac_modes list in opentherm_gw (#142074) 2025-06-30 19:35:24 +00:00
Pete Sage
5a0a1bbbf4 Person ble_trackers for non-home zones not processed correctly (#138475)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-30 19:35:23 +00:00
Paulus Schoutsen
cf2e69ed74 Bump version to 2025.7.0b4 2025-06-28 20:27:42 +00:00
J. Nick Koston
c32b44b774 Improve rest error logging (#147736)
* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* top level
2025-06-28 20:27:20 +00:00
Florian von Garrel
2f69ed4a8a bump pypaperless to 4.1.1 (#147735) 2025-06-28 20:27:19 +00:00
Marc Hörsken
4b3449fe0c Fix error if cover position is not available or unknown (#147732) 2025-06-28 20:27:18 +00:00
starkillerOG
33e1c6de68 Reduce idle timeout of HLS stream to conserve camera battery life (#147728)
* Reduce IDLE timeout of HLS stream to conserve camera battery life

* adjust tests
2025-06-28 20:27:17 +00:00
Daniel Hjelseth Høyer
81e712ea49 Bump pytibber to 0.31.6 (#147703) 2025-06-28 20:27:16 +00:00
Shay Levy
d3c5684cd0 Fix Shelly Block entity removal (#147694) 2025-06-28 20:27:16 +00:00
Jan Bouwhuis
862b7460b5 Move MQTT device sw and hw version to collapsed section in subentry flow (#147685)
Move MQTT device sw and hw version to collapsed section
2025-06-28 20:27:15 +00:00
Samuel Xiao
a65eb57539 Add lock models to switchbot cloud (#147569) 2025-06-28 20:27:14 +00:00
Antoni Czaplicki
b537850f52 Bump vulcan-api to 2.4.2 (#146857) 2025-06-28 20:27:13 +00:00
Franck Nijhof
16c6bd08f8 Bump version to 2025.7.0b3 2025-06-27 17:55:31 +00:00
Simone Chemelli
18834849c2 Bump aioamazondevices to 3.1.22 (#147681) 2025-06-27 17:54:40 +00:00
hanwg
e4d820799f Add codeowner for Telegram bot (#147680) 2025-06-27 17:54:38 +00:00
mkmer
013a35176a Bump aiosomecomfort to 0.0.33 (#147673) 2025-06-27 17:54:37 +00:00
Norbert Rittel
8230557aef Fix sentence-casing and spacing of button in thermopro (#147671) 2025-06-27 17:54:36 +00:00
Paul Bottein
5451063714 Update frontend to 20250627.0 (#147668) 2025-06-27 17:54:35 +00:00
Shay Levy
8cdc7523a4 Fix Shelly entity removal (#147665) 2025-06-27 17:54:33 +00:00
Josef Zweck
77ccfbd3a9 Fix: Unhandled NoneType sessions in jellyfin (#147659) 2025-06-27 17:54:32 +00:00
Josef Zweck
4977ee4998 Bump jellyfin-apiclient-python to 1.11.0 (#147658) 2025-06-27 17:54:31 +00:00
Josef Zweck
5c0f2d37f0 Make jellyfin not single config entry (#147656) 2025-06-27 17:54:29 +00:00
Thomas55555
0b5d2ab8e4 Respect availability of parent class in Husqvarna Automower (#147649) 2025-06-27 17:54:28 +00:00
Brett Adams
47f3bf29dd Fix energy history in Teslemetry (#147646) 2025-06-27 17:54:26 +00:00
Manu
62f7cbb51e Remove dweet.io integration (#147645) 2025-06-27 17:54:25 +00:00
Bernardus Jansen
b9e2c5d34c Add previously missing state classes to dsmr sensors (#147633) 2025-06-27 17:54:24 +00:00
Petar Petrov
1829acd0e1 Z-WaveJS config flow: Change keys question (#147518)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 17:54:22 +00:00
Franck Nijhof
41b9a7a9a3 Bump version to 2025.7.0b2 2025-06-27 08:08:02 +00:00
Norbert Rittel
9782637ec8 Clarify descriptions of subaru.unlock_specific_door action (#147655) 2025-06-27 08:05:06 +00:00
Manu
6bd6fa65d2 Bump pynecil to v4.1.1 (#147648) 2025-06-27 08:05:05 +00:00
Joost Lekkerkerker
85343a9f53 Make sure Ollama integration migration is clean (#147630) 2025-06-27 08:05:04 +00:00
Joost Lekkerkerker
bc607dd013 Make sure Anthropic integration migration is clean (#147629) 2025-06-27 08:05:02 +00:00
Joost Lekkerkerker
c2c388e0cc Make sure OpenAI integration migration is clean (#147627) 2025-06-27 08:05:01 +00:00
Joost Lekkerkerker
3fc154e1d7 Make sure Google Generative AI integration migration is clean (#147625) 2025-06-27 08:05:00 +00:00
Jack Powell
efb29d024e Add Diagnostics to PlayStation Network (#147607)
* Add Diagnostics support to PlayStation_Network

* Remove unused constant

* minor cleanup

* Redact additional data

* Redact additional data
2025-06-27 08:04:58 +00:00
Michael
263823c92c Fix config schema to make credentials optional in NUT flows (#147593) 2025-06-27 08:04:57 +00:00
hanwg
e5e6ed601b Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) 2025-06-27 08:04:56 +00:00
Petar Petrov
28dfc997f3 Do not factory reset old Z-Wave controller during migration (#147576)
* Do not factory reset old Z-Wave controller during migration

* PR comments

* remove obsolete test
2025-06-27 08:04:55 +00:00
puddly
f93ab8d519 Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 08:04:54 +00:00
Josef Zweck
cb359da79e Make entities unavailable when machine is physically off in lamarzocco (#147426) 2025-06-27 08:04:52 +00:00
Franck Nijhof
6a7385590a Bump version to 2025.7.0b1 2025-06-26 18:03:11 +00:00
Joost Lekkerkerker
c0ec987b07 Fix meaters not being added after a reload (#147614) 2025-06-26 18:02:49 +00:00
Joost Lekkerkerker
26521f8cc0 Hide Telegram bot proxy URL behind section (#147613)
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2025-06-26 18:02:48 +00:00
Manu
4df1f702bf Fix asset url in Habitica integration (#147612) 2025-06-26 18:02:46 +00:00
Joost Lekkerkerker
c8422c9fb8 Improve explanation on how to get API token in Telegram (#147605) 2025-06-26 18:02:45 +00:00
Luca Angemi
f8207a2e0e Remove default icon for wind direction sensor for Buienradar (#147603)
* Fix wind direction state class sensor

* Remove default icon for wind direction sensor
2025-06-26 18:02:44 +00:00
Bram Kragten
9cc75f3458 Update frontend to 20250626.0 (#147601) 2025-06-26 18:02:43 +00:00
Joost Lekkerkerker
a233b6b1e3 Add default title to migrated Ollama entry (#147599) 2025-06-26 18:02:42 +00:00
Joost Lekkerkerker
c7677b91da Add default title to migrated Claude entry (#147598) 2025-06-26 18:02:40 +00:00
Joost Lekkerkerker
1f57bba9cd Add default conversation name for OpenAI integration (#147597) 2025-06-26 18:02:39 +00:00
Joost Lekkerkerker
4cc10ca2e2 Set Google AI model as device model (#147582)
* Set Google AI model as device model

* fix
2025-06-26 18:02:38 +00:00
Marcel van der Veldt
153e1e43e8 Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) 2025-06-26 18:02:36 +00:00
Joost Lekkerkerker
398dd3ae46 Set right model in OpenAI conversation (#147575) 2025-06-26 18:02:35 +00:00
Petar Petrov
17fd850fa6 Hide unnamed paths when selecting a USB Z-Wave adapter (#147571)
* Hide unnamed paths when selecting a USB Z-Wave adapter

* remove pointless sorting
2025-06-26 18:02:34 +00:00
Petar Petrov
ae062b230c Remove obsolete routing info when migrating a Z-Wave network (#147568) 2025-06-26 18:02:33 +00:00
Marcel van der Veldt
d523f85404 Fix sending commands to Matter vacuum (#147567) 2025-06-26 18:02:31 +00:00
tronikos
f28d6582c6 Refactor in Google AI TTS in preparation for STT (#147562) 2025-06-26 18:02:30 +00:00
Petar Petrov
1e81e5990e Bump zwave-js-server-python to 0.65.0 (#147561)
* Bump zwave-js-server-python to 0.65.0

* update tests
2025-06-26 18:02:29 +00:00
tronikos
5fe2e4b6ed Include subentries in Google Generative AI diagnostics (#147558) 2025-06-26 18:02:28 +00:00
tronikos
914bb3aa76 Use default title for migrated Google Generative AI entries (#147551) 2025-06-26 18:02:26 +00:00
Simone Chemelli
cfa6746115 Fix unload for Alexa Devices (#147548) 2025-06-26 18:02:25 +00:00
Simone Chemelli
03f9caf3eb Add action exceptions to Alexa Devices (#147546) 2025-06-26 18:02:24 +00:00
Joost Lekkerkerker
6b2aaf3fdb Show current Lametric version if there is no newer version (#147538) 2025-06-26 18:02:23 +00:00
Luca Angemi
2c4ea0d584 Fix wind direction state class sensor for AEMET (#147535) 2025-06-26 18:02:21 +00:00
Anders Peter Fugmann
e627811f7a Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) 2025-06-26 18:02:20 +00:00
Simone Chemelli
150f41641b Improve config flow strings for Alexa Devices (#147523) 2025-06-26 18:02:19 +00:00
Erik Montnemery
b9a7371996 Set end date for when allowing unique id collisions in config entries (#147516)
* Set end date for when allowing unique id collisions in config entries

* Update test
2025-06-26 18:02:17 +00:00
tronikos
7d0e99da43 Fixes in Google AI TTS (#147501)
* Fix Google AI not using correct config options after subentries migration

* Fixes in Google AI TTS

* Fix tests by @IvanLH

* Change type name.

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-06-26 18:02:16 +00:00
hanwg
71f281cc14 Fix Telegram bot default target when sending messages (#147470)
* handle targets

* updated error message

* validate chat id for single target

* add validation for chat id

* handle empty target

* handle empty target
2025-06-26 18:02:15 +00:00
Renat Sibgatulin
aec812a475 Create a new client session for air-Q to fix cookie polution (#147027) 2025-06-26 18:00:50 +00:00
Robin Lintermann
d4b548b169 Fixed issue when tests (should) fail in Smarla (#146102)
* Fixed issue when tests (should) fail

* Use usefixture decorator

* Throw ConfigEntryError instead of AuthFailed
2025-06-26 18:00:48 +00:00
Fabio Natanael Kepler
a296324c30 Fix playing TTS and local media source over DLNA (#134903)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 18:00:47 +00:00
Franck Nijhof
cff3d3d6ac Bump version to 2025.7.0b0 2025-06-25 18:51:19 +00:00
Erik Montnemery
26e3caea9a Add support for condition platforms to provide multiple conditions (#147376) 2025-06-25 18:10:30 +01:00
Bouwe Westerdijk
2b5f5f641d Bump plugwise to v1.7.6 (#147508) 2025-06-25 18:48:38 +02:00
Simone Chemelli
99079d2980 Bump aioamazondevices to 3.1.19 (#147462) 2025-06-25 18:47:09 +02:00
Retha Runolfsson
2800921a5d Remove force latch mode for locklite in switchbot integration (#147474) 2025-06-25 18:45:37 +02:00
Jan Bouwhuis
3268b9ee18 Fix typo's in MQTT translation strings (#147489) 2025-06-25 18:45:09 +02:00
Bram Kragten
02c3cdd5d4 Update frontend to 20250625.0 (#147521) 2025-06-25 18:44:46 +02:00
Manu
f34f17bc24 Update codeowners of PlayStation Network integration (#147510)
Add myself as codeowner
2025-06-25 18:35:48 +02:00
Erik Montnemery
1fb587bf03 Allow core integrations to describe their triggers (#147075)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-06-25 17:35:15 +01:00
Pete Sage
d8258924f7 Remove mapping of entity_ids to speakers in Sonos (#147506)
* fix

* fix: change entity_id mappings

* fix: translate errors

* fix:merge issues

* fix: translate error messages

* fix: improve test coverage

* fix: remove unneeded strings
2025-06-25 18:29:23 +02:00
Retha Runolfsson
c05d8aab1c Add floor lamp and strip light 3 for switchbot integration (#147517) 2025-06-25 18:01:10 +02:00
Nathan Larsen
e210681751 Fix API POST endpoints json parsing error-handling (#134326)
* Fix API POST endpoints json parsing error-handling

* Add tests

* Fix mypy and ruff errors

* Fix coverage by removing non-needed error handling

* Correct error handling and improve tests

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Erik <erik@montnemery.com>
2025-06-25 16:58:21 +02:00
Thomas D
809aced9cc Add cover platform to Qbus integration (#147420)
* Add scene platform

* Add cover platform

* Refactor receiving state

* Fix wrong auto-merged code
2025-06-25 15:38:43 +02:00
ocrease
977e8adbfb Fix operational state and vacuum state for matter vacuum (#147466) 2025-06-25 15:23:38 +02:00
Michael
c54ce7eabd Split models and helpers from coordinator module in AVM Fritz!Box tools (#147412)
* split models from coordinator

* split helpers from coordinator
2025-06-25 14:50:07 +02:00
Retha Runolfsson
c5f8acfe93 Add effect mode support for switchbot light (#147326)
* add support for strip light3 and floor lamp

* clear the color mode

* add led unit test

* use property for effect

* fix color mode issue

* remove new products

* fix adv data

* adjust log level

* add translation and icon
2025-06-25 14:45:07 +02:00
Pavel Skuratovich
8393f17bb3 Fix sensor state class for fuel sensor in StarLine integration (#146769) 2025-06-25 14:34:11 +02:00
Guido Schmitz
8918b0d7a9 Add missing reauth_confirm strings to devolo Home Control (#147496) 2025-06-25 14:33:37 +02:00
Manu
c447729ce4 Add sensor platform to PlayStation Network (#147469) 2025-06-25 14:33:02 +02:00
Guido Schmitz
12812049ea Split setup tests in devolo Home Network (#147498) 2025-06-25 14:14:33 +02:00
J. Nick Koston
47811e13a6 Bump PySwitchbot to 0.67.0 (#147503)
changelog: https://github.com/sblibs/pySwitchbot/compare/0.66.0...0.67.0
2025-06-25 13:58:39 +02:00
Erik Montnemery
7587fc985f Bump py-dormakaba-dkey to 1.0.6 (#147499) 2025-06-25 13:31:43 +02:00
puddly
716ec1eef2 Bump ZHA to 0.0.61 (#147472)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-06-25 13:27:57 +02:00
Gábor Kiss
b95af2d86b Fix ESPHome entity_id generation if name contains unicode characters (#146796)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-06-25 13:19:55 +02:00
Andre Lengwenus
bca7502611 Add quality scale for LCN (#147367)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-06-25 11:50:00 +01:00
J. Diego Rodríguez Royo
1e4fbebf49 Improve Home Connect diagnostics exposing more data (#147492) 2025-06-25 11:49:54 +02:00
Pete Sage
c9e9575a3d Add tests for join and unjoin service calls in Sonos (#145602)
* fix: add tests for join and unjoin

* fix: update comments

* fix: update comments

* fix: refactor to common functions

* fix: refactor to common functions

* fix: add type def

* fix: add return types

* fix: add return types

* fix: correct type annontation for uui_ds

* fix: update comments

* fix: merge issues

* fix: merge issue

* fix: raise homeassistanterror on timeout

* fix: add comments

* fix: simplify test

* fix: simplify test

* fix: simplify test
2025-06-25 11:38:51 +02:00
tronikos
f897a728f1 Fix Google AI not using correct config options after subentries migration (#147493) 2025-06-25 11:25:01 +02:00
J. Diego Rodríguez Royo
0bbb168862 Add Home Connect DHCP information (#147494)
* Add Home Connect DHCP information

* Add tests
2025-06-25 11:24:38 +02:00
J. Nick Koston
0a884c7253 Add subdevices support to ESPHome (#147343) 2025-06-25 21:24:30 +12:00
Joakim Sørensen
58e60fdfac Bump hass-nabucasa from 0.103.0 to 0.104.0 (#147488) 2025-06-25 11:15:09 +02:00
Joost Lekkerkerker
33bd35bff4 Migrate Meater to use HassKey (#147485)
* Migrate Meater to use HassKey

* Update homeassistant/components/meater/sensor.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Migrate Meater to use HassKey

* Migrate Meater to use HassKey

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-06-25 10:36:58 +02:00
Simone Rescio
f4b95ff5f1 Ezviz battery camera work mode (#130478)
* Add support for EzViz Battery Camera work mode

* feat: address review comment, add 'battery' to work mode string

* feat: optimize entity addition for Ezviz select component

* refactor: streamline error handling in Ezviz select actions

* Update library

* update library

* Bump api to pin mqtt to compatable version

* fix after rebase

* Update code owners

* codeowners

* Add support for EzViz Battery Camera work mode

* feat: address review comment, add 'battery' to work mode string

* feat: optimize entity addition for Ezviz select component

* refactor: streamline error handling in Ezviz select actions

* feat: address review item simplify Ezviz select actions by removing base class and moving methods

* chore: fix ruff lint

* feat: check for SupportExt before adding battery select

* chore: cleanup logging

* feat: restored battery work mode, separated defnitions for sound and battery selects, check SupportExt with type casting

* Apply suggestions from code review

---------

Co-authored-by: Pierre-Jean Buffard <pierre-jean.buffard@dataiku.com>
Co-authored-by: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-25 09:41:18 +02:00
Simone Chemelli
f800248c10 Add more binary sensors to Alexa Devices (#146402)
* Add more binary sensors to Amazon Devices

* apply review comment

* Add sensor platform to Amazon Devices

* Revert "Add sensor platform to Amazon Devices"

This reverts commit 25a9ca673e450634a17bdb79462b14aa855aca10.

* clean

* fix logic after latest changes

* apply review comments
2025-06-25 09:33:13 +02:00
Retha Runolfsson
d0b2d1dc92 Add evaporative humidifier for switchbot integration (#146235)
* add support for evaporative humidifier

* add evaporative humidifier unit test

* clear the humidifier action in pyswitchbot

* fix ruff

* fix Sentence-casing issue

* add icon translation

* remove last run success

* use icon translations for water level

* remove the translation for last run success
2025-06-25 09:32:33 +02:00
Jan Bouwhuis
85e9919bbd Add entity category option to entities set up via an MQTT subentry (#146776)
* Add entity category option to entities set up via an MQTT subentry

* Rephrase

* typo

* Move entity category to entity details - remove service to action

* Move entity category to entity platform config flow step
2025-06-25 09:28:37 +02:00
Joost Lekkerkerker
51fb1ab8b6 Refactor Meater availability (#146956)
* Refactor Meater availability

* Fix

* Fix
2025-06-25 09:23:27 +02:00
epenet
066e840e06 Migrate lookin to use runtime_data (#147479) 2025-06-25 09:17:43 +02:00
Joost Lekkerkerker
7031167895 Set has entity name to True in Meater (#146954)
* Set has entity name to True in Meater

* Fix

* Fix
2025-06-25 08:59:28 +02:00
epenet
69bf79d3bd Migrate local_calendar to use runtime_data (#147481) 2025-06-25 08:47:29 +02:00
epenet
909d950b50 Migrate luftdaten to use runtime_data (#147480) 2025-06-25 08:07:34 +02:00
epenet
51da1bc25a Migrate loqed to use runtime_data (#147478)
* Migrate loqed to use runtime_data

* Fix tests
2025-06-25 08:07:17 +02:00
epenet
f22b623968 Move luftdaten coordinator to separate module (#147477) 2025-06-25 07:48:56 +02:00
epenet
2bcdc03661 Migrate lupusec to use runtime_data (#147476) 2025-06-25 07:48:30 +02:00
epenet
10d1affd81 Migrate lyric to use runtime_data (#147475) 2025-06-25 07:48:20 +02:00
Manu
91e7b75a44 Fix errors in legacy platform in PlayStation Network integration (#147471)
fix legacy platform presence
2025-06-25 06:48:45 +02:00
natepugh
42aaa888a1 Bump pyairnow to 1.3.1 (#147388)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-06-24 23:47:56 +01:00
Guido Schmitz
7b8ebb0803 Move DevoloMultiLevelSwitchDeviceEntity in devolo Home Control (#147450) 2025-06-24 22:42:42 +02:00
Paulus Schoutsen
c270ea4e0c Fix media accept config type (#147445) 2025-06-24 16:41:43 -04:00
Paulus Schoutsen
c93e45c0f2 Add missing config entry type for Husqvarna (#147455)
Add missing type for husqvarna
2025-06-24 22:37:35 +02:00
Michael Hansen
19b773df85 Only send ESPHome intent progress when necessary (#147458)
* Only send intent progress when necessary

* cover

* Fix logic

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2025-06-24 16:35:38 -04:00
puddly
9e7c7ec97e Flash ZBT-1 and Yellow firmwares from Core instead of using addons (#145019)
* Make `async_flash_firmware` a public helper

* [ZBT-1] Implement flashing for Zigbee and Thread within the config flow

* WIP: Begin fixing unit tests

* WIP: Unit tests, pass 2

* WIP: pass 3

* Fix hardware unit tests

* Have the individual hardware integrations depend on the firmware flasher

* Break out firmware filter into its own helper

* Mirror to Yellow

* Simplify

* Simplify

* Revert "Have the individual hardware integrations depend on the firmware flasher"

This reverts commit 096f4297dc.

* Move `async_flash_silabs_firmware` into `util`

* Fix existing unit tests

* Unconditionally upgrade Zigbee firmware during installation

* Fix failing error case unit tests

* Fix remaining failing unit tests

* Increase test coverage

* 100% test coverage

* Remove old translation strings

* Add new translation strings

* Do not probe OTBR firmware when completing the flow

* More translation strings

* Probe OTBR firmware info before starting the addon
2025-06-24 16:21:02 -04:00
Paulus Schoutsen
f735331699 Convert Ollama to subentries (#147286)
* Convert Ollama to subentries

* Add latest changes from Google subentries

* Move config entry type to init
2025-06-24 16:13:34 -04:00
Maciej Bieniek
5a20ef3f3f Bump aioshelly to version 13.7.0 (#147453) 2025-06-24 23:03:22 +03:00
Simone Chemelli
5ef054f2e0 Add quality scale bronze to SamsungTV (#142288) 2025-06-24 21:41:39 +02:00
Manu
b9fc198a7e Set quality scale to 🥇 gold for ista EcoTrend integration (#143462) 2025-06-24 21:25:53 +02:00
HarvsG
ad4fae7f59 Custom sentence triggers should be marked as processed locally (#145704)
* Mark custom sentence triggers a local agent

* Don't change agent ID

* adds tests to confirm processed_locally is True

* move asserts to after null check
2025-06-24 14:25:40 -05:00
Paulus Schoutsen
265de91fba Add type for wiz (#147454) 2025-06-24 15:13:51 -04:00
Paul Bottein
7322fe40da Define fields for assist ask_question action (#147219)
* Define fields for assist ask_question action

* Update hassfest

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-06-24 15:00:14 -04:00
Paulus Schoutsen
8eb906fad9 Migrate OpenAI to config subentries (#147282)
* Migrate OpenAI to config subentries

* Add latest changes from Google subentries

* Update homeassistant/components/openai_conversation/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-24 15:00:05 -04:00
Sven Naumann
4d9843172b Fix nfandroidtv service notify disappears when restarting home assistant (#128958)
* move connect to android tv host from init to short before sending a message

* Don't swallow exceptions

* use string literals for exception

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-24 20:09:45 +02:00
Abílio Costa
e8a534be9c Add missing method mock to Reolink chime test (#147447) 2025-06-24 20:06:54 +02:00
Abílio Costa
3148719864 Use newer mock in recent Reolink test (#147448) 2025-06-24 19:06:42 +01:00
Nathan Spencer
abfb7afcb7 Bump pylitterbot to 2024.2.1 (#147443) 2025-06-24 19:26:35 +02:00
Abílio Costa
fe4ff4f835 Use non-autospec mock for Reolink switch tests (#147441) 2025-06-24 19:19:41 +02:00
Michael Hansen
cefc8822b6 Support streaming TTS in wyoming (#147392)
* Support streaming TTS in wyoming

* Add test

* Refactor to avoid repeated task creation

* Manually manage client lifecycle
2025-06-24 13:04:40 -04:00
Michael Hansen
3dc8676b99 Add TTS streaming to Wyoming satellites (#147438)
* Add TTS streaming using intent-progress

* Handle incomplete header
2025-06-24 12:00:02 -05:00
Abílio Costa
0f112bb9c4 Use non-autospec mock for Reolink service tests (#147440) 2025-06-24 18:37:05 +02:00
Nathan Spencer
54e5107c34 Add total cycles sensor for Litter-Robot (#147435)
* Add total cycles sensor for Litter-Robot

* Add translatable unit of measurement cycles
2025-06-24 18:24:15 +02:00
karwosts
657a068087 Cleanup some duplicated code (#147439) 2025-06-24 17:22:13 +01:00
Luca Angemi
af6c2b5c8a Add device class to wind direction sensors for AEMET (#147430) 2025-06-24 16:25:16 +01:00
441 changed files with 16925 additions and 5866 deletions

6
CODEOWNERS generated
View File

@@ -1169,8 +1169,8 @@ build.json @home-assistant/supervisor
/tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan
/tests/components/plaato/ @JohNan
/homeassistant/components/playstation_network/ @jackjpowell
/tests/components/playstation_network/ @jackjpowell
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
@@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor
/tests/components/technove/ @Moustachauve
/homeassistant/components/tedee/ @patrickhilker @zweckj
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/telegram_bot/ @hanwg
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @home-assistant/core

View File

@@ -89,6 +89,7 @@ from .helpers import (
restore_state,
template,
translation,
trigger,
)
from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
@@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(trigger.async_setup(hass)),
)

View File

@@ -185,6 +185,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
name="Daily forecast wind bearing",
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
entity_registry_enabled_default=False,
@@ -192,6 +193,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
name="Hourly forecast wind bearing",
native_unit_of_measurement=DEGREE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
entity_registry_enabled_default=False,
@@ -334,7 +336,8 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
name="Wind bearing",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
device_class=SensorDeviceClass.WIND_DIRECTION,
),
AemetSensorEntityDescription(
key=ATTR_API_WIND_MAX_SPEED,

View File

@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
data = {}
data: dict[str, Any] = {}
try:
obs = await self.airnow.observations.latLong(
self.latitude,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airnow",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.2.1"]
"requirements": ["pyairnow==1.3.1"]
}

View File

@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
session = async_get_clientsession(hass)
session = async_create_clientsession(hass)
self.airq = AirQ(
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
)

View File

@@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await coordinator.api.close()
return unload_ok

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from typing import Final
from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = (
@@ -36,13 +38,49 @@ BINARY_SENSORS: Final = (
key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda _device: _device.online,
is_on_fn=lambda device, _: device.online,
),
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda _device: _device.bluetooth_state,
is_on_fn=lambda device, _: device.bluetooth_state,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
)
@@ -60,6 +98,7 @@ async def async_setup_entry(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
)
@@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.is_on_fn(self.device)
return self.entity_description.is_on_fn(
self.device, self.entity_description.key
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -36,6 +36,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except WrongCountry:
errors["base"] = "wrong_country"
else:
await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured()

View File

@@ -2,9 +2,39 @@
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth",
"default": "mdi:bluetooth-off",
"state": {
"off": "mdi:bluetooth-off"
"on": "mdi:bluetooth"
}
},
"baby_cry_detection": {
"default": "mdi:account-voice-off",
"state": {
"on": "mdi:account-voice"
}
},
"beeping_appliance_detection": {
"default": "mdi:bell-off",
"state": {
"on": "mdi:bell-ring"
}
},
"cough_detection": {
"default": "mdi:blur-off",
"state": {
"on": "mdi:blur"
}
},
"dog_bark_detection": {
"default": "mdi:dog-side-off",
"state": {
"on": "mdi:dog-side"
}
},
"water_sounds_detection": {
"default": "mdi:water-pump-off",
"state": {
"on": "mdi:water-pump"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.14"]
"requirements": ["aioamazondevices==3.2.2"]
}

View File

@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:

View File

@@ -26,7 +26,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo

View File

@@ -1,8 +1,7 @@
{
"common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.",
"data_description_country": "The country where your Amazon account is registered.",
"data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
@@ -12,10 +11,10 @@
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"country": "[%key:common::config_flow::data::country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
"code": "[%key:component::alexa_devices::common::data_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
@@ -34,6 +33,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
@@ -41,6 +41,21 @@
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
},
"baby_cry_detection": {
"name": "Baby crying"
},
"beeping_appliance_detection": {
"name": "Beeping appliance"
},
"cough_detection": {
"name": "Coughing"
},
"dog_bark_detection": {
"name": "Dog barking"
},
"water_sounds_detection": {
"name": "Water sounds"
}
},
"notify": {
@@ -56,5 +71,13 @@
"name": "Do not disturb"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Error connecting: {error}"
},
"cannot_retrieve_data": {
"message": "Error retrieving data: {error}"
}
}
}

View File

@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)

View File

@@ -0,0 +1,40 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper

View File

@@ -17,7 +17,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
from .const import (
CONF_CHAT_MODEL,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -117,12 +123,49 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_CONVERSATION_NAME,
options={},
version=2,
minor_version=2,
)
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
VERSION = 2
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None

View File

@@ -260,11 +260,18 @@ class APIEntityStateView(HomeAssistantView):
if not user.is_admin:
raise Unauthorized(entity_id=entity_id)
hass = request.app[KEY_HASS]
body = await request.text()
try:
data = await request.json()
data: Any = json_loads(body) if body else None
except ValueError:
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
if not isinstance(data, dict):
return self.json_message(
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
)
if (new_state := data.get("state")) is None:
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
@@ -477,9 +484,19 @@ class APITemplateView(HomeAssistantView):
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Render a template."""
body = await request.text()
try:
data: Any = json_loads(body) if body else None
except ValueError:
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
if not isinstance(data, dict):
return self.json_message(
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
)
tpl = _cached_template(data["template"], request.app[KEY_HASS])
try:
data = await request.json()
tpl = _cached_template(data["template"], request.app[KEY_HASS])
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
except (ValueError, TemplateError) as ex:
return self.json_message(

View File

@@ -1119,6 +1119,7 @@ class PipelineRun:
) is not None:
# Sentence trigger matched
agent_id = "sentence_trigger"
processed_locally = True
intent_response = intent.IntentResponse(
self.pipeline.conversation_language
)

View File

@@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
cv.make_entity_service_schema(
{
vol.Optional("message"): str,
vol.Optional("media_id"): str,
vol.Optional("media_id"): _media_id_validator,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("preannounce_media_id"): _media_id_validator,
}
),
cv.has_at_least_one_key("message", "media_id"),
@@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_internal_announce",
[AssistSatelliteEntityFeature.ANNOUNCE],
)
component.async_register_entity_service(
"start_conversation",
vol.All(
cv.make_entity_service_schema(
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str,
vol.Optional("start_media_id"): _media_id_validator,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("extra_system_prompt"): str,
}
),
@@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
vol.Optional("question"): str,
vol.Optional("question_media_id"): str,
vol.Optional("question_media_id"): _media_id_validator,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("answers"): [
{
vol.Required("id"): str,
@@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]:
raise vol.Invalid("sentences cannot be empty")
return value
# Validator for media_id fields that accepts both string and media selector format
_media_id_validator = vol.Any(
cv.string, # Plain string format
vol.All(
vol.Schema(
{
vol.Required("media_content_id"): cv.string,
vol.Required("media_content_type"): cv.string,
vol.Remove("metadata"): dict, # Ignore metadata if present
}
),
# Extract media_content_id from media selector format
lambda x: x["media_content_id"],
),
)

View File

@@ -14,7 +14,9 @@ announce:
media_id:
required: false
selector:
text:
media:
accept:
- audio/*
preannounce:
required: false
default: true
@@ -23,7 +25,9 @@ announce:
preannounce_media_id:
required: false
selector:
text:
media:
accept:
- audio/*
start_conversation:
target:
entity:
@@ -40,7 +44,9 @@ start_conversation:
start_media_id:
required: false
selector:
text:
media:
accept:
- audio/*
extra_system_prompt:
required: false
selector:
@@ -53,7 +59,9 @@ start_conversation:
preannounce_media_id:
required: false
selector:
text:
media:
accept:
- audio/*
ask_question:
fields:
entity_id:
@@ -72,7 +80,9 @@ ask_question:
question_media_id:
required: false
selector:
text:
media:
accept:
- audio/*
preannounce:
required: false
default: true
@@ -81,8 +91,24 @@ ask_question:
preannounce_media_id:
required: false
selector:
text:
media:
accept:
- audio/*
answers:
required: false
selector:
object:
label_field: sentences
description_field: id
multiple: true
translation_key: answers
fields:
id:
required: true
selector:
text:
sentences:
required: true
selector:
text:
multiple: true

View File

@@ -90,5 +90,13 @@
}
}
}
},
"selector": {
"answers": {
"fields": {
"id": "Answer ID",
"sentences": "Sentences"
}
}
}
}

View File

@@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1",
"bluetooth-data-tools==1.28.2",
"dbus-fast==2.43.0",
"habluetooth==3.49.0"
]

View File

@@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key="windazimuth",
translation_key="windazimuth",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),

View File

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

View File

@@ -336,13 +336,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
"" if unit is None else unit
)
# filter out all derivatives older than `time_window` from our window list
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
if (new_state.last_reported - time_end).total_seconds()
< self._time_window
]
self._prune_state_list(new_state.last_reported)
try:
elapsed_time = (
@@ -380,25 +374,14 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
(old_last_reported, new_state.last_reported, new_derivative)
)
def calculate_weight(
start: datetime, end: datetime, now: datetime
) -> float:
window_start = now - timedelta(seconds=self._time_window)
if start < window_start:
weight = (end - window_start).total_seconds() / self._time_window
else:
weight = (end - start).total_seconds() / self._time_window
return weight
# If outside of time window just report derivative (is the same as modeling it in the window),
# otherwise take the weighted average with the previous derivatives
if elapsed_time > self._time_window:
derivative = new_derivative
else:
derivative = Decimal("0.00")
for start, end, value in self._state_list:
weight = calculate_weight(start, end, new_state.last_reported)
derivative = derivative + (value * Decimal(weight))
derivative = self._calc_derivative_from_state_list(
new_state.last_reported
)
self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state()

View File

@@ -10,6 +10,7 @@ from homeassistant.const import CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
trace_condition_function,
)
@@ -51,20 +52,38 @@ class DeviceAutomationConditionProtocol(Protocol):
"""List conditions."""
async def async_validate_condition_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate device condition config."""
return await async_validate_device_automation_config(
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
)
class DeviceCondition(Condition):
"""Device condition."""
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Initialize condition."""
self._config = config
self._hass = hass
@classmethod
async def async_validate_condition_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate device condition config."""
return await async_validate_device_automation_config(
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
)
async def async_condition_from_config(self) -> condition.ConditionCheckerType:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(
platform.async_condition_from_config(self._hass, self._config)
)
async def async_condition_from_config(
hass: HomeAssistant, config: ConfigType
) -> condition.ConditionCheckerType:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(platform.async_condition_from_config(hass, config))
CONDITIONS: dict[str, type[Condition]] = {
"device": DeviceCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
return CONDITIONS

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
from .entity import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
from .entity import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(

View File

@@ -1,27 +0,0 @@
"""Base class for multi level switches in devolo Home Control."""
from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
from .entity import DevoloDeviceEntity
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
_attr_name = None
def __init__(
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
) -> None:
"""Initialize a multi level switch within devolo Home Control."""
super().__init__(
homecontrol=homecontrol,
device_instance=device_instance,
element_uid=element_uid,
)
self._multi_level_switch_property = device_instance.multi_level_switch_property[
element_uid
]
self._value = self._multi_level_switch_property.value

View File

@@ -90,3 +90,24 @@ class DevoloDeviceEntity(Entity):
self._attr_available = self._device_instance.is_online()
else:
_LOGGER.debug("No valid message received: %s", message)
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
_attr_name = None
def __init__(
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
) -> None:
"""Initialize a multi level switch within devolo Home Control."""
super().__init__(
homecontrol=homecontrol,
device_instance=device_instance,
element_uid=element_uid,
)
self._multi_level_switch_property = device_instance.multi_level_switch_property[
element_uid
]
self._value = self._multi_level_switch_property.value

View File

@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
from .entity import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
from .entity import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(

View File

@@ -19,6 +19,16 @@
"password": "Password of your mydevolo account."
}
},
"reauth_confirm": {
"data": {
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]",
"password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]"
}
},
"zeroconf_confirm": {
"data": {
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyW215"],
"requirements": ["pyW215==0.7.0"]
"requirements": ["pyW215==0.8.0"]
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["py-dormakaba-dkey==1.0.5"]
"requirements": ["py-dormakaba-dkey==1.0.6"]
}

View File

@@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [
native_unit_of_measurement=UnitOfVolume.GALLONS,
suggested_display_precision=1,
value_fn=lambda device: device.drop_api.water_used_today(),
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
),
DROPSensorEntityDescription(
key=AVERAGE_WATER_USED,

View File

@@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="SHORT_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="LONG_POWER_FAILURE_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SAG_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L1_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L2_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(
@@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
obis_reference="VOLTAGE_SWELL_L3_COUNT",
dsmr_versions={"2.2", "4", "5", "5L"},
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
),
DSMRSensorEntityDescription(

View File

@@ -1,79 +0,0 @@
"""Support for sending data to Dweet.io."""
from datetime import timedelta
import logging
import dweepy
import voluptuous as vol
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_NAME,
CONF_WHITELIST,
EVENT_STATE_CHANGED,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
DOMAIN = "dweet"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_WHITELIST, default=[]): vol.All(
cv.ensure_list, [cv.entity_id]
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dweet.io component."""
conf = config[DOMAIN]
name = conf.get(CONF_NAME)
whitelist = conf.get(CONF_WHITELIST)
json_body = {}
def dweet_event_listener(event):
"""Listen for new messages on the bus and sends them to Dweet.io."""
state = event.data.get("new_state")
if (
state is None
or state.state in (STATE_UNKNOWN, "")
or state.entity_id not in whitelist
):
return
try:
_state = state_helper.state_as_number(state)
except ValueError:
_state = state.state
json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state
send_data(name, json_body)
hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
return True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def send_data(name, msg):
"""Send the collected data to Dweet.io."""
try:
dweepy.dweet_for(name, msg)
except dweepy.DweepyError:
_LOGGER.error("Error saving data to Dweet.io: %s", msg)

View File

@@ -1,10 +0,0 @@
{
"domain": "dweet",
"name": "dweet.io",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dweet",
"iot_class": "cloud_polling",
"loggers": ["dweepy"],
"quality_scale": "legacy",
"requirements": ["dweepy==0.3.0"]
}

View File

@@ -1,124 +0,0 @@
"""Support for showing values from Dweet.io."""
from __future__ import annotations
from datetime import timedelta
import json
import logging
import dweepy
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Dweet.io Sensor"
SCAN_INTERVAL = timedelta(minutes=1)
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_DEVICE): cv.string,
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dweet sensor."""
name = config.get(CONF_NAME)
device = config.get(CONF_DEVICE)
value_template = config.get(CONF_VALUE_TEMPLATE)
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
try:
content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"])
except dweepy.DweepyError:
_LOGGER.error("Device/thing %s could not be found", device)
return
if value_template and value_template.render_with_possible_json_value(content) == "":
_LOGGER.error("%s was not found", value_template)
return
dweet = DweetData(device)
add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True)
class DweetSensor(SensorEntity):
"""Representation of a Dweet sensor."""
def __init__(self, hass, dweet, name, value_template, unit_of_measurement):
"""Initialize the sensor."""
self.hass = hass
self.dweet = dweet
self._name = name
self._value_template = value_template
self._state = None
self._unit_of_measurement = unit_of_measurement
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
@property
def native_value(self):
"""Return the state."""
return self._state
def update(self) -> None:
"""Get the latest data from REST API."""
self.dweet.update()
if self.dweet.data is None:
self._state = None
else:
values = json.dumps(self.dweet.data[0]["content"])
self._state = self._value_template.render_with_possible_json_value(
values, None
)
class DweetData:
"""The class for handling the data retrieval."""
def __init__(self, device):
"""Initialize the sensor."""
self._device = device
self.data = None
def update(self):
"""Get the latest data from Dweet.io."""
try:
self.data = dweepy.get_latest_dweet_for(self._device)
except dweepy.DweepyError:
_LOGGER.warning("Device %s doesn't contain any data", self._device)
self.data = None

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
}

View File

@@ -284,11 +284,15 @@ class EsphomeAssistSatellite(
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
data_to_send = {
"tts_start_streaming": "1"
if (event.data and event.data.get("tts_start_streaming"))
else "0",
}
if (
not event.data
or ("tts_start_streaming" not in event.data)
or (not event.data["tts_start_streaming"])
):
# ESPHome only needs to know if early TTS streaming is available
return
data_to_send = {"tts_start_streaming": "1"}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
assert event.data is not None
data_to_send = {

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
import functools
import logging
import math
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast
@@ -13,7 +14,6 @@ from aioesphomeapi import (
EntityCategory as EsphomeEntityCategory,
EntityInfo,
EntityState,
build_unique_id,
)
import voluptuous as vol
@@ -24,6 +24,7 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -32,9 +33,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
from .enum_mapper import EsphomeEnumMapper
_LOGGER = logging.getLogger(__name__)
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
_StateT = TypeVar("_StateT", bound=EntityState)
@@ -53,21 +56,74 @@ def async_static_info_updated(
) -> None:
"""Update entities of this platform when entities are listed."""
current_infos = entry_data.info[info_type]
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
new_infos: dict[int, EntityInfo] = {}
add_entities: list[_EntityT] = []
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
for info in infos:
if not current_infos.pop(info.key, None):
# Create new entity
new_infos[info.key] = info
# Create new entity if it doesn't exist
if not (old_info := current_infos.pop(info.key, None)):
entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity)
new_infos[info.key] = info
continue
# Entity exists - check if device_id has changed
if old_info.device_id == info.device_id:
continue
# Entity has switched devices, need to migrate unique_id
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
# If entity not found in registry, re-add it
# This happens when the device_id changed and the old device was deleted
if entity_id is None:
_LOGGER.info(
"Entity with old unique_id %s not found in registry after device_id "
"changed from %s to %s, re-adding entity",
old_unique_id,
old_info.device_id,
info.device_id,
)
entity = entity_type(entry_data, platform.domain, info, state_type)
add_entities.append(entity)
continue
updates: dict[str, Any] = {}
new_unique_id = build_device_unique_id(device_info.mac_address, info)
# Update unique_id if it changed
if old_unique_id != new_unique_id:
updates["new_unique_id"] = new_unique_id
# Update device assignment
if info.device_id:
# Entity now belongs to a sub device
new_device = dev_reg.async_get_device(
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
)
else:
# Entity now belongs to the main device
new_device = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
if new_device:
updates["device_id"] = new_device.id
# Apply all updates at once
if updates:
ent_reg.async_update_entity(entity_id, **updates)
# Anything still in current_infos is now gone
if current_infos:
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
entry_data.async_remove_entities(
hass, current_infos.values(), device_info.mac_address
)
@@ -225,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool
_has_state: bool = False
unique_id: str
def __init__(
@@ -244,11 +300,28 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
self._key = entity_info.key
self._state_type = state_type
self._on_static_info_update(entity_info)
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
device_name = device_info.name
# Determine the device connection based on whether this entity belongs to a sub device
if entity_info.device_id:
# Entity belongs to a sub device
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
}
)
# Use the pre-computed device_id_to_name mapping for O(1) lookup
device_name = entry_data.device_id_to_name.get(
entity_info.device_id, device_info.name
)
else:
# Entity belongs to the main device
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
if entity_info.name:
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
self.entity_id = f"{domain}.{device_name}_{entity_info.name}"
else:
# https://github.com/home-assistant/core/issues/132532
# If name is not set, ESPHome will use the sanitized friendly name
@@ -256,7 +329,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
# as the entity_id before it is sanitized since the sanitizer
# is not utf-8 aware. In this case, its always going to be
# an empty string so we drop the object_id.
self.entity_id = f"{domain}.{device_info.name}"
self.entity_id = f"{domain}.{device_name}"
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -290,7 +363,9 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
static_info = cast(_InfoT, static_info)
assert device_info
self._static_info = static_info
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
self._attr_unique_id = build_device_unique_id(
device_info.mac_address, static_info
)
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
# https://github.com/home-assistant/core/issues/132532
# If the name is "", we need to set it to None since otherwise

View File

@@ -95,6 +95,22 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
}
def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
"""Build unique ID for entity, appending @device_id if it belongs to a sub-device.
This wrapper around build_unique_id ensures that entities belonging to sub-devices
have their device_id appended to the unique_id to handle proper migration when
entities move between devices.
"""
base_unique_id = build_unique_id(mac, entity_info)
# If entity belongs to a sub-device, append @device_id
if entity_info.device_id:
return f"{base_unique_id}@{entity_info.device_id}"
return base_unique_id
class StoreData(TypedDict, total=False):
"""ESPHome storage data."""
@@ -160,6 +176,7 @@ class RuntimeEntryData:
assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
default_factory=list
)
device_id_to_name: dict[int, str] = field(default_factory=dict)
@property
def name(self) -> str:
@@ -222,7 +239,9 @@ class RuntimeEntryData:
ent_reg = er.async_get(hass)
for info in static_infos:
if entry := ent_reg.async_get_entity_id(
INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
INFO_TYPE_TO_PLATFORM[type(info)],
DOMAIN,
build_device_unique_id(mac, info),
):
ent_reg.async_remove(entry)
@@ -278,7 +297,8 @@ class RuntimeEntryData:
if (
(old_unique_id := info.unique_id)
and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
and (new_unique_id := build_device_unique_id(mac, info))
!= old_unique_id
and not registry_get_entity(platform, DOMAIN, new_unique_id)
):
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)

View File

@@ -527,6 +527,11 @@ class ESPHomeManager:
device_info.name,
device_mac,
)
# Build device_id_to_name mapping for efficient lookup
entry_data.device_id_to_name = {
sub_device.device_id: sub_device.name or device_info.name
for sub_device in device_info.devices
}
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
entry_data.async_update_device_state()
@@ -751,6 +756,28 @@ def _async_setup_device_registry(
device_info = entry_data.device_info
if TYPE_CHECKING:
assert device_info is not None
device_registry = dr.async_get(hass)
# Build sets of valid device identifiers and connections
valid_connections = {
(dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address))
}
valid_identifiers = {
(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")
for sub_device in device_info.devices
}
# Remove devices that no longer exist
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
# Skip devices we want to keep
if (
device.connections & valid_connections
or device.identifiers & valid_identifiers
):
continue
# Remove everything else
device_registry.async_remove_device(device.id)
sw_version = device_info.esphome_version
if device_info.compilation_time:
sw_version += f" ({device_info.compilation_time})"
@@ -779,11 +806,14 @@ def _async_setup_device_registry(
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
)
suggested_area = None
if device_info.suggested_area:
suggested_area: str | None = None
if device_info.area and device_info.area.name:
# Prefer device_info.area over suggested_area when area name is not empty
suggested_area = device_info.area.name
elif device_info.suggested_area:
suggested_area = device_info.suggested_area
device_registry = dr.async_get(hass)
# Create/update main device
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
configuration_url=configuration_url,
@@ -794,6 +824,36 @@ def _async_setup_device_registry(
sw_version=sw_version,
suggested_area=suggested_area,
)
# Handle sub devices
# Find available areas from device_info
areas_by_id = {area.area_id: area for area in device_info.areas}
# Add the main device's area if it exists
if device_info.area:
areas_by_id[device_info.area.area_id] = device_info.area
# Create/update sub devices that should exist
for sub_device in device_info.devices:
# Determine the area for this sub device
sub_device_suggested_area: str | None = None
if sub_device.area_id is not None and sub_device.area_id in areas_by_id:
sub_device_suggested_area = areas_by_id[sub_device.area_id].name
sub_device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")},
name=sub_device.name or device_entry.name,
manufacturer=manufacturer,
model=model,
sw_version=sw_version,
suggested_area=sub_device_suggested_area,
)
# Update the sub device to set via_device_id
device_registry.async_update_device(
sub_device_entry.id,
via_device_id=device_entry.id,
)
return device_entry.id

View File

@@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
# if the string is empty
if unit_of_measurement := static_info.unit_of_measurement:
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_suggested_display_precision = static_info.accuracy_decimals
self._attr_device_class = try_parse_enum(
SensorDeviceClass, static_info.device_class
)
@@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
self._attr_state_class = _STATE_CLASSES.from_esphome(state_class)
@property
def native_value(self) -> datetime | str | None:
def native_value(self) -> datetime | int | float | None:
"""Return the state of the entity."""
if not self._has_state or (state := self._state).missing_state:
return None
@@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
return None
if self.device_class is SensorDeviceClass.TIMESTAMP:
return dt_util.utc_from_timestamp(state_float)
return f"{state_float:.{self._static_info.accuracy_decimals}f}"
return state_float
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):

View File

@@ -2,9 +2,17 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pyezvizapi.constants import DeviceSwitchType, SoundMode
from pyezvizapi.constants import (
BatteryCameraWorkMode,
DeviceCatagories,
DeviceSwitchType,
SoundMode,
SupportExt,
)
from pyezvizapi.exceptions import HTTPError, PyEzvizError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -24,17 +32,83 @@ class EzvizSelectEntityDescription(SelectEntityDescription):
"""Describe a EZVIZ Select entity."""
supported_switch: int
current_option: Callable[[EzvizSelect], str | None]
select_option: Callable[[EzvizSelect, str, str], None]
SELECT_TYPE = EzvizSelectEntityDescription(
def alarm_sound_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
"""Return the selected entity option to represent the entity state."""
sound_mode_value = getattr(
SoundMode, ezvizSelect.data[ezvizSelect.entity_description.key]
).value
if sound_mode_value in [0, 1, 2]:
return ezvizSelect.options[sound_mode_value]
return None
def alarm_sound_mode_select_option(
ezvizSelect: EzvizSelect, serial: str, option: str
) -> None:
"""Change the selected option."""
sound_mode_value = ezvizSelect.options.index(option)
ezvizSelect.coordinator.ezviz_client.alarm_sound(serial, sound_mode_value, 1)
ALARM_SOUND_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
key="alarm_sound_mod",
translation_key="alarm_sound_mode",
entity_category=EntityCategory.CONFIG,
options=["soft", "intensive", "silent"],
supported_switch=DeviceSwitchType.ALARM_TONE.value,
current_option=alarm_sound_mode_current_option,
select_option=alarm_sound_mode_select_option,
)
def battery_work_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
"""Return the selected entity option to represent the entity state."""
battery_work_mode = getattr(
BatteryCameraWorkMode,
ezvizSelect.data[ezvizSelect.entity_description.key],
BatteryCameraWorkMode.UNKNOWN,
)
if battery_work_mode == BatteryCameraWorkMode.UNKNOWN:
return None
return battery_work_mode.name.lower()
def battery_work_mode_select_option(
ezvizSelect: EzvizSelect, serial: str, option: str
) -> None:
"""Change the selected option."""
battery_work_mode = getattr(BatteryCameraWorkMode, option.upper())
ezvizSelect.coordinator.ezviz_client.set_battery_camera_work_mode(
serial, battery_work_mode.value
)
BATTERY_WORK_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
key="battery_camera_work_mode",
translation_key="battery_camera_work_mode",
icon="mdi:battery-sync",
entity_category=EntityCategory.CONFIG,
options=[
"plugged_in",
"high_performance",
"power_save",
"super_power_save",
"custom",
],
supported_switch=-1,
current_option=battery_work_mode_current_option,
select_option=battery_work_mode_select_option,
)
SELECT_TYPES = [ALARM_SOUND_MODE_SELECT_TYPE, BATTERY_WORK_MODE_SELECT_TYPE]
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
@@ -43,12 +117,26 @@ async def async_setup_entry(
"""Set up EZVIZ select entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
EzvizSelect(coordinator, camera)
entities = [
EzvizSelect(coordinator, camera, ALARM_SOUND_MODE_SELECT_TYPE)
for camera in coordinator.data
for switch in coordinator.data[camera]["switches"]
if switch == SELECT_TYPE.supported_switch
)
if switch == ALARM_SOUND_MODE_SELECT_TYPE.supported_switch
]
for camera in coordinator.data:
device_category = coordinator.data[camera].get("device_category")
supportExt = coordinator.data[camera].get("supportExt")
if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and supportExt
and str(SupportExt.SupportBatteryManage.value) in supportExt
):
entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
)
async_add_entities(entities)
class EzvizSelect(EzvizEntity, SelectEntity):
@@ -58,31 +146,23 @@ class EzvizSelect(EzvizEntity, SelectEntity):
self,
coordinator: EzvizDataUpdateCoordinator,
serial: str,
description: EzvizSelectEntityDescription,
) -> None:
"""Initialize the sensor."""
"""Initialize the select entity."""
super().__init__(coordinator, serial)
self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}"
self.entity_description = SELECT_TYPE
self._attr_unique_id = f"{serial}_{description.key}"
self.entity_description = description
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
sound_mode_value = getattr(
SoundMode, self.data[self.entity_description.key]
).value
if sound_mode_value in [0, 1, 2]:
return self.options[sound_mode_value]
return None
desc = cast(EzvizSelectEntityDescription, self.entity_description)
return desc.current_option(self)
def select_option(self, option: str) -> None:
"""Change the selected option."""
sound_mode_value = self.options.index(option)
desc = cast(EzvizSelectEntityDescription, self.entity_description)
try:
self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1)
return desc.select_option(self, self._serial, option)
except (HTTPError, PyEzvizError) as err:
raise HomeAssistantError(
f"Cannot set Warning sound level for {self.entity_id}"
) from err
raise HomeAssistantError(f"Cannot select option for {desc.key}") from err

View File

@@ -68,6 +68,16 @@
"intensive": "Intensive",
"silent": "Silent"
}
},
"battery_camera_work_mode": {
"name": "Battery work mode",
"state": {
"plugged_in": "Plugged in",
"high_performance": "High performance",
"power_save": "Power save",
"super_power_save": "Super power saving",
"custom": "Custom"
}
}
},
"image": {

View File

@@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ConnectionInfo, FritzConfigEntry
from .coordinator import FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
_LOGGER = logging.getLogger(__name__)

View File

@@ -19,15 +19,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
_is_tracked,
)
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import _is_tracked
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, ValuesView
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from functools import partial
@@ -34,7 +34,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -48,6 +47,15 @@ from .const import (
FRITZ_EXCEPTIONS,
MeshRoles,
)
from .helpers import _ha_is_stopping
from .models import (
ConnectionInfo,
Device,
FritzDevice,
HostAttributes,
HostInfo,
Interface,
)
_LOGGER = logging.getLogger(__name__)
@@ -56,33 +64,13 @@ FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN)
type FritzConfigEntry = ConfigEntry[AvmWrapper]
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
"""Check if device is already tracked."""
return any(mac in tracked for tracked in current_devices)
@dataclass
class FritzData:
"""Storage class for platform global data."""
def device_filter_out_from_trackers(
mac: str,
device: FritzDevice,
current_devices: ValuesView[set[str]],
) -> bool:
"""Check if device should be filtered out from trackers."""
reason: str | None = None
if device.ip_address == "":
reason = "Missing IP"
elif _is_tracked(mac, current_devices):
reason = "Already tracked"
if reason:
_LOGGER.debug(
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
)
return bool(reason)
def _ha_is_stopping(activity: str) -> None:
"""Inform that HA is stopping."""
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
tracked: dict[str, set[str]] = field(default_factory=dict)
profile_switches: dict[str, set[str]] = field(default_factory=dict)
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
class ClassSetupMissing(Exception):
@@ -93,68 +81,6 @@ class ClassSetupMissing(Exception):
super().__init__("Function called before Class setup")
@dataclass
class Device:
"""FRITZ!Box device class."""
connected: bool
connected_to: str
connection_type: str
ip_address: str
name: str
ssid: str | None
wan_access: bool | None = None
class Interface(TypedDict):
"""Interface details."""
device: str
mac: str
op_mode: str
ssid: str | None
type: str
HostAttributes = TypedDict(
"HostAttributes",
{
"Index": int,
"IPAddress": str,
"MACAddress": str,
"Active": bool,
"HostName": str,
"InterfaceType": str,
"X_AVM-DE_Port": int,
"X_AVM-DE_Speed": int,
"X_AVM-DE_UpdateAvailable": bool,
"X_AVM-DE_UpdateSuccessful": str,
"X_AVM-DE_InfoURL": str | None,
"X_AVM-DE_MACAddressList": str | None,
"X_AVM-DE_Model": str | None,
"X_AVM-DE_URL": str | None,
"X_AVM-DE_Guest": bool,
"X_AVM-DE_RequestClient": str,
"X_AVM-DE_VPN": bool,
"X_AVM-DE_WANAccess": str,
"X_AVM-DE_Disallow": bool,
"X_AVM-DE_IsMeshable": str,
"X_AVM-DE_Priority": str,
"X_AVM-DE_FriendlyName": str,
"X_AVM-DE_FriendlyNameIsWriteable": str,
},
)
class HostInfo(TypedDict):
"""FRITZ!Box host info class."""
mac: str
name: str
ip: str
status: bool
class UpdateCoordinatorDataType(TypedDict):
"""Update coordinator data type."""
@@ -898,120 +824,3 @@ class AvmWrapper(FritzBoxTools):
"X_AVM-DE_WakeOnLANByMACAddress",
NewMACAddress=mac_address,
)
@dataclass
class FritzData:
"""Storage class for platform global data."""
tracked: dict[str, set[str]] = field(default_factory=dict)
profile_switches: dict[str, set[str]] = field(default_factory=dict)
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
class FritzDevice:
"""Representation of a device connected to the FRITZ!Box."""
def __init__(self, mac: str, name: str) -> None:
"""Initialize device info."""
self._connected = False
self._connected_to: str | None = None
self._connection_type: str | None = None
self._ip_address: str | None = None
self._last_activity: datetime | None = None
self._mac = mac
self._name = name
self._ssid: str | None = None
self._wan_access: bool | None = False
def update(self, dev_info: Device, consider_home: float) -> None:
"""Update device info."""
utc_point_in_time = dt_util.utcnow()
if self._last_activity:
consider_home_evaluated = (
utc_point_in_time - self._last_activity
).total_seconds() < consider_home
else:
consider_home_evaluated = dev_info.connected
if not self._name:
self._name = dev_info.name or self._mac.replace(":", "_")
self._connected = dev_info.connected or consider_home_evaluated
if dev_info.connected:
self._last_activity = utc_point_in_time
self._connected_to = dev_info.connected_to
self._connection_type = dev_info.connection_type
self._ip_address = dev_info.ip_address
self._ssid = dev_info.ssid
self._wan_access = dev_info.wan_access
@property
def connected_to(self) -> str | None:
"""Return connected status."""
return self._connected_to
@property
def connection_type(self) -> str | None:
"""Return connected status."""
return self._connection_type
@property
def is_connected(self) -> bool:
"""Return connected status."""
return self._connected
@property
def mac_address(self) -> str:
"""Get MAC address."""
return self._mac
@property
def hostname(self) -> str:
"""Get Name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Get IP address."""
return self._ip_address
@property
def last_activity(self) -> datetime | None:
"""Return device last activity."""
return self._last_activity
@property
def ssid(self) -> str | None:
"""Return device connected SSID."""
return self._ssid
@property
def wan_access(self) -> bool | None:
"""Return device wan access."""
return self._wan_access
class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
callback_switch: Callable
init_state: bool
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
ipv6_active: bool

View File

@@ -10,15 +10,10 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
device_filter_out_from_trackers,
)
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import device_filter_out_from_trackers
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)

View File

@@ -14,7 +14,8 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_DEVICE_NAME, DOMAIN
from .coordinator import AvmWrapper, FritzDevice
from .coordinator import AvmWrapper
from .models import FritzDevice
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):

View File

@@ -0,0 +1,39 @@
"""Helpers for AVM FRITZ!Box."""
from __future__ import annotations
from collections.abc import ValuesView
import logging
from .models import FritzDevice
_LOGGER = logging.getLogger(__name__)
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
"""Check if device is already tracked."""
return any(mac in tracked for tracked in current_devices)
def device_filter_out_from_trackers(
mac: str,
device: FritzDevice,
current_devices: ValuesView[set[str]],
) -> bool:
"""Check if device should be filtered out from trackers."""
reason: str | None = None
if device.ip_address == "":
reason = "Missing IP"
elif _is_tracked(mac, current_devices):
reason = "Already tracked"
if reason:
_LOGGER.debug(
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
)
return bool(reason)
def _ha_is_stopping(activity: str) -> None:
"""Inform that HA is stopping."""
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)

View File

@@ -0,0 +1,182 @@
"""Models for AVM FRITZ!Box."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
from homeassistant.util import dt as dt_util
from .const import MeshRoles
@dataclass
class Device:
"""FRITZ!Box device class."""
connected: bool
connected_to: str
connection_type: str
ip_address: str
name: str
ssid: str | None
wan_access: bool | None = None
class Interface(TypedDict):
"""Interface details."""
device: str
mac: str
op_mode: str
ssid: str | None
type: str
HostAttributes = TypedDict(
"HostAttributes",
{
"Index": int,
"IPAddress": str,
"MACAddress": str,
"Active": bool,
"HostName": str,
"InterfaceType": str,
"X_AVM-DE_Port": int,
"X_AVM-DE_Speed": int,
"X_AVM-DE_UpdateAvailable": bool,
"X_AVM-DE_UpdateSuccessful": str,
"X_AVM-DE_InfoURL": str | None,
"X_AVM-DE_MACAddressList": str | None,
"X_AVM-DE_Model": str | None,
"X_AVM-DE_URL": str | None,
"X_AVM-DE_Guest": bool,
"X_AVM-DE_RequestClient": str,
"X_AVM-DE_VPN": bool,
"X_AVM-DE_WANAccess": str,
"X_AVM-DE_Disallow": bool,
"X_AVM-DE_IsMeshable": str,
"X_AVM-DE_Priority": str,
"X_AVM-DE_FriendlyName": str,
"X_AVM-DE_FriendlyNameIsWriteable": str,
},
)
class HostInfo(TypedDict):
"""FRITZ!Box host info class."""
mac: str
name: str
ip: str
status: bool
class FritzDevice:
"""Representation of a device connected to the FRITZ!Box."""
def __init__(self, mac: str, name: str) -> None:
"""Initialize device info."""
self._connected = False
self._connected_to: str | None = None
self._connection_type: str | None = None
self._ip_address: str | None = None
self._last_activity: datetime | None = None
self._mac = mac
self._name = name
self._ssid: str | None = None
self._wan_access: bool | None = False
def update(self, dev_info: Device, consider_home: float) -> None:
"""Update device info."""
utc_point_in_time = dt_util.utcnow()
if self._last_activity:
consider_home_evaluated = (
utc_point_in_time - self._last_activity
).total_seconds() < consider_home
else:
consider_home_evaluated = dev_info.connected
if not self._name:
self._name = dev_info.name or self._mac.replace(":", "_")
self._connected = dev_info.connected or consider_home_evaluated
if dev_info.connected:
self._last_activity = utc_point_in_time
self._connected_to = dev_info.connected_to
self._connection_type = dev_info.connection_type
self._ip_address = dev_info.ip_address
self._ssid = dev_info.ssid
self._wan_access = dev_info.wan_access
@property
def connected_to(self) -> str | None:
"""Return connected status."""
return self._connected_to
@property
def connection_type(self) -> str | None:
"""Return connected status."""
return self._connection_type
@property
def is_connected(self) -> bool:
"""Return connected status."""
return self._connected
@property
def mac_address(self) -> str:
"""Get MAC address."""
return self._mac
@property
def hostname(self) -> str:
"""Get Name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Get IP address."""
return self._ip_address
@property
def last_activity(self) -> datetime | None:
"""Return device last activity."""
return self._last_activity
@property
def ssid(self) -> str | None:
"""Return device connected SSID."""
return self._ssid
@property
def wan_access(self) -> bool | None:
"""Return device wan access."""
return self._wan_access
class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
callback_switch: Callable
init_state: bool
@dataclass
class ConnectionInfo:
"""Fritz sensor connection information class."""
connection: str
mesh_role: MeshRoles
wan_enabled: bool
ipv6_active: bool

View File

@@ -27,8 +27,9 @@ from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from .const import DSL_CONNECTION, UPTIME_DEVIATION
from .coordinator import ConnectionInfo, FritzConfigEntry
from .coordinator import FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
_LOGGER = logging.getLogger(__name__)

View File

@@ -25,16 +25,10 @@ from .const import (
WIFI_STANDARD,
MeshRoles,
)
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
FritzConfigEntry,
FritzData,
FritzDevice,
SwitchInfo,
device_filter_out_from_trackers,
)
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzBoxBaseEntity, FritzDeviceBase
from .helpers import device_filter_out_from_trackers
from .models import FritzDevice, SwitchInfo
_LOGGER = logging.getLogger(__name__)

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from types import MappingProxyType
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -35,12 +36,14 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_TTS_OPTIONS,
TIMEOUT_MILLIS,
)
@@ -190,7 +193,7 @@ async def async_setup_entry(
client = await hass.async_add_executor_job(_init_client)
await client.aio.models.get(
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
model=RECOMMENDED_CHAT_MODEL,
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
except (APIError, Timeout) as err:
@@ -204,6 +207,8 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
@@ -217,6 +222,13 @@ async def async_unload_entry(
return True
async def async_update_options(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""
@@ -243,6 +255,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
if use_existing:
hass.config_entries.async_add_subentry(
parent_entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)
conversation_entity = entity_registry.async_get_entity_id(
"conversation",
DOMAIN,
@@ -271,12 +293,65 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if not use_existing:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_TITLE,
options={},
version=2,
minor_version=2,
)
async def async_migrate_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Add TTS subentry which was missing in 2025.7.0b0
if not any(
subentry.subentry_type == "tts" for subentry in entry.subentries.values()
):
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -47,13 +47,18 @@ from .const import (
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_MODEL,
RECOMMENDED_TTS_OPTIONS,
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
TIMEOUT_MILLIS,
)
@@ -66,12 +71,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -93,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Generative AI Conversation."""
VERSION = 2
MINOR_VERSION = 2
async def async_step_api(
self, user_input: dict[str, Any] | None = None
@@ -118,15 +118,21 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
data=user_input,
)
return self.async_create_entry(
title="Google Generative AI",
title=DEFAULT_TITLE,
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
@@ -172,10 +178,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
return {
"conversation": LLMSubentryFlowHandler,
"tts": LLMSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
class LLMSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
last_rendered_recommended = False
@@ -202,7 +211,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input is None:
if self._is_new:
options = RECOMMENDED_OPTIONS.copy()
options: dict[str, Any]
if self._subentry_type == "tts":
options = RECOMMENDED_TTS_OPTIONS.copy()
else:
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
else:
# If this is a reconfiguration, we need to copy the existing options
# so that we can show the current values in the form.
@@ -216,7 +229,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
# Don't allow to save options that enable the Google Seearch tool with an Assist API
# Don't allow to save options that enable the Google Search tool with an Assist API
if not (
user_input.get(CONF_LLM_HASS_API)
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
@@ -240,7 +253,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
options = user_input
schema = await google_generative_ai_config_option_schema(
self.hass, self._is_new, options, self._genai_client
self.hass, self._is_new, self._subentry_type, options, self._genai_client
)
return self.async_show_form(
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
@@ -253,6 +266,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
is_new: bool,
subentry_type: str,
options: Mapping[str, Any],
genai_client: genai.Client,
) -> dict:
@@ -270,26 +284,39 @@ async def google_generative_ai_config_option_schema(
suggested_llm_apis = [suggested_llm_apis]
if is_new:
if CONF_NAME in options:
default_name = options[CONF_NAME]
elif subentry_type == "tts":
default_name = DEFAULT_TTS_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
vol.Required(CONF_NAME, default=default_name): str,
}
else:
schema = {}
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
)
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -303,14 +330,15 @@ async def google_generative_ai_config_option_schema(
api_models = [api_model async for api_model in api_models_pager]
models = [
SelectOptionDict(
label=api_model.display_name,
label=api_model.name.lstrip("models/"),
value=api_model.name,
)
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
for api_model in sorted(
api_models, key=lambda x: x.name.lstrip("models/") or ""
)
if (
api_model.display_name
and api_model.name
and "tts" not in api_model.name
api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name
and api_model.supported_actions
and "generateContent" in api_model.supported_actions
@@ -341,12 +369,17 @@ async def google_generative_ai_config_option_schema(
)
)
if subentry_type == "tts":
default_model = RECOMMENDED_TTS_MODEL
else:
default_model = RECOMMENDED_CHAT_MODEL
schema.update(
{
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=RECOMMENDED_CHAT_MODEL,
default=default_model,
): SelectSelector(
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
),
@@ -396,13 +429,18 @@ async def google_generative_ai_config_option_schema(
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
return schema

View File

@@ -2,17 +2,21 @@
import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
DOMAIN = "google_generative_ai_conversation"
DEFAULT_TITLE = "Google Generative AI"
LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
DEFAULT_TTS_NAME = "Google AI TTS"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
@@ -31,3 +35,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_RECOMMENDED: True,
}
RECOMMENDED_TTS_OPTIONS = {
CONF_RECOMMENDED: True,
}

View File

@@ -61,9 +61,6 @@ class GoogleGenerativeAIConversationEntity(
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -103,10 +100,3 @@ class GoogleGenerativeAIConversationEntity(
conversation_id=chat_log.conversation_id,
continue_conversation=chat_log.continue_conversation,
)
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics(
"title": entry.title,
"data": entry.data,
"options": entry.options,
"subentries": dict(entry.subentries),
},
TO_REDACT,
)

View File

@@ -301,7 +301,12 @@ async def _transform_stream(
class GoogleGenerativeAILLMBaseEntity(Entity):
"""Google Generative AI base entity."""
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
def __init__(
self,
entry: ConfigEntry,
subentry: ConfigSubentry,
default_model: str = RECOMMENDED_CHAT_MODEL,
) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
@@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
model="Generative AI",
model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1],
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -337,7 +342,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
@@ -389,47 +394,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
generateContentConfig = self.create_generate_content_config()
generateContentConfig.tools = tools or None
generateContentConfig.system_instruction = (
prompt if supports_system_instruction else None
)
generateContentConfig.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
)
if not supports_system_instruction:
@@ -472,3 +443,40 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if not chat_log.unresponded_tool_results:
break
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
)

View File

@@ -0,0 +1,73 @@
"""Helper classes for Google Generative AI integration."""
from __future__ import annotations
from contextlib import suppress
import io
import wave
from homeassistant.exceptions import HomeAssistantError
from .const import LOGGER
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = _parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
# Below code is from https://aistudio.google.com/app/generate-speech
# when you select "Get SDK code to generate speech".
def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}

View File

@@ -29,7 +29,6 @@
"reconfigure": "Reconfigure conversation agent"
},
"entry_type": "Conversation agent",
"step": {
"set_options": {
"data": {
@@ -61,6 +60,34 @@
"error": {
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
}
},
"tts": {
"initiate_flow": {
"user": "Add Text-to-Speech service",
"reconfigure": "Reconfigure Text-to-Speech service"
},
"entry_type": "Text-to-Speech",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"chat_model": "[%key:common::generic::model%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
}
}
},
"abort": {
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"services": {

View File

@@ -2,13 +2,12 @@
from __future__ import annotations
from contextlib import suppress
import io
import logging
from collections.abc import Mapping
from typing import Any
import wave
from google.genai import types
from google.genai.errors import APIError, ClientError
from propcache.api import cached_property
from homeassistant.components.tts import (
ATTR_VOICE,
@@ -16,15 +15,14 @@ from homeassistant.components.tts import (
TtsAudioType,
Voice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
_LOGGER = logging.getLogger(__name__)
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
async def async_setup_entry(
@@ -32,15 +30,23 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TTS entity."""
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
async_add_entities([tts_entity])
"""Set up TTS entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "tts":
continue
async_add_entities(
[GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
class GoogleGenerativeAITextToSpeechEntity(
TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity
):
"""Google Generative AI text-to-speech entity."""
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_supported_options = [ATTR_VOICE]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
_attr_supported_languages = [
"ar-EG",
@@ -68,6 +74,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
"uk-UA",
"vi-VN",
]
# Unused, but required by base class.
# The Gemini TTS models detect the input language automatically.
_attr_default_language = "en-US"
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
_supported_voices = [
@@ -106,110 +114,44 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
)
]
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Google Generative AI Conversation speech entity."""
self.entry = entry
self._attr_name = "Google Generative AI TTS"
self._attr_unique_id = f"{entry.entry_id}_tts"
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._genai_client = entry.runtime_data
self._default_voice_id = self._supported_voices[0].voice_id
def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the TTS entity."""
super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL)
@callback
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
def async_get_supported_voices(self, language: str) -> list[Voice]:
"""Return a list of supported voices for a language."""
return self._supported_voices
@cached_property
def default_options(self) -> Mapping[str, Any]:
"""Return a mapping with the default options."""
return {
ATTR_VOICE: self._supported_voices[0].voice_id,
}
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
try:
response = self._genai_client.models.generate_content(
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options.get(
ATTR_VOICE, self._default_voice_id
)
)
)
),
),
config = self.create_generate_content_config()
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options[ATTR_VOICE]
)
)
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=config,
)
data = response.candidates[0].content.parts[0].inline_data.data
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
except Exception as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
except (APIError, ClientError, ValueError) as exc:
LOGGER.error("Error during TTS: %s", exc, exc_info=True)
raise HomeAssistantError(exc) from exc
return "wav", self._convert_to_wav(data, mime_type)
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = self._parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
return "wav", convert_to_wav(data, mime_type)

View File

@@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
SIGN_UP_URL = "https://habitica.com/register"
HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png"
DOMAIN = "habitica"

View File

@@ -11,6 +11,7 @@ from urllib.parse import quote
import aiohttp
from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web
from aiohttp.helpers import must_be_empty_body
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
from multidict import CIMultiDict
from yarl import URL
@@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView):
content_type = "application/octet-stream"
# Simple request
if result.status in (204, 304) or (
if (empty_body := must_be_empty_body(result.method, result.status)) or (
content_length is not UNDEFINED
and (content_length_int := int(content_length))
<= MAX_SIMPLE_RESPONSE_SIZE
):
# Return Response
body = await result.read()
if empty_body:
body = None
else:
body = await result.read()
simple_response = web.Response(
headers=headers,
status=result.status,

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from aiohomeconnect.model import GetSetting, Status
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
@@ -11,14 +13,30 @@ from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
def _serialize_item(item: Status | GetSetting) -> dict[str, Any]:
"""Serialize a status or setting item to a dictionary."""
data = {"value": item.value}
if item.unit is not None:
data["unit"] = item.unit
if item.constraints is not None:
data["constraints"] = {
k: v for k, v in item.constraints.to_dict().items() if v is not None
}
return data
async def _generate_appliance_diagnostics(
appliance: HomeConnectApplianceData,
) -> dict[str, Any]:
return {
**appliance.info.to_dict(),
"status": {key.value: status.value for key, status in appliance.status.items()},
"status": {
key.value: _serialize_item(status)
for key, status in appliance.status.items()
},
"settings": {
key.value: setting.value for key, setting in appliance.settings.items()
key.value: _serialize_item(setting)
for key, setting in appliance.settings.items()
},
"programs": [program.raw_key for program in appliance.programs],
}

View File

@@ -14,7 +14,7 @@
"macaddress": "68A40E*"
},
{
"hostname": "(siemens|neff)-*",
"hostname": "(bosch|neff|siemens)-*",
"macaddress": "38B4D3*"
}
],

View File

@@ -7,6 +7,11 @@ import asyncio
import logging
from typing import Any
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
from universal_silabs_flasher.common import Version
from universal_silabs_flasher.firmware import NabuCasaMetadata
from homeassistant.components.hassio import (
AddonError,
AddonInfo,
@@ -22,17 +27,18 @@ from homeassistant.config_entries import (
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
async_flash_silabs_firmware,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_firmware_info,
guess_hardware_owners,
probe_silabs_firmware_info,
@@ -61,6 +67,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task | None = None
self.installing_firmware_name: str | None = None
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""
@@ -77,22 +85,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return placeholders
async def _async_set_addon_config(
self, config: dict, addon_manager: AddonManager
) -> None:
"""Set add-on config."""
try:
await addon_manager.async_set_addon_options(config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
) from err
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
"""Return add-on info."""
try:
@@ -150,6 +142,145 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
)
)
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
assert self._device is not None
if not self.firmware_install_task:
# Keep track of the firmware we're working with, for error messages
self.installing_firmware_name = firmware_name
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type
)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
_LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True
)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to index download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
return self.async_show_progress_done(
next_step_id="firmware_download_failed"
)
if not firmware_install_required:
assert self._probed_firmware_info is not None
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return self.async_show_progress_done(next_step_id=next_step_id)
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError):
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to image download failure"
)
return self.async_show_progress_done(next_step_id=next_step_id)
# Otherwise, fail
return self.async_show_progress_done(
next_step_id="firmware_download_failed"
)
self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_type=None,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
),
f"Flash {firmware_name} firmware",
)
if not self.firmware_install_task.done():
return self.async_show_progress(
step_id=step_id,
progress_action="install_firmware",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task,
)
try:
await self.firmware_install_task
except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed")
return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware download failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_firmware_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware install failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -160,68 +291,141 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
# Allow the stick to be used with ZHA without flashing
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
return await self.async_step_confirm_zigbee()
return await self.async_step_install_zigbee_firmware()
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
raise NotImplementedError
# Only flash new firmware if we need to
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(fw_flasher_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_zigbee_flasher_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_run_zigbee_flasher_addon()
# If the addon is already installed and running, fail
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason="addon_already_running",
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
"addon_name": self._failed_addon_name,
},
)
async def async_step_install_zigbee_flasher_addon(
async def async_step_pre_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the Zigbee flasher addon."""
return await self._install_addon(
get_zigbee_flasher_addon_manager(self.hass),
"install_zigbee_flasher_addon",
"run_zigbee_flasher_addon",
"""Pre-confirm Zigbee setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_zigbee()
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm 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(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
async def _install_addon(
self,
addon_manager: silabs_multiprotocol_addon.WaitingAddonManager,
step_id: str,
next_step_id: str,
return self._async_flow_finished()
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
"""Ensure the OTBR addon is set up and not running."""
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
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
await otbr_manager.async_stop_addon()
return None
async def async_step_pick_firmware_thread(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing an addon."""
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if result := await self._ensure_thread_addon_setup():
return result
return await self.async_step_install_thread_firmware()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
raise NotImplementedError
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager)
_LOGGER.debug("Flasher addon state: %s", addon_info)
_LOGGER.debug("OTBR addon info: %s", addon_info)
if not self.addon_install_task:
self.addon_install_task = self.hass.async_create_task(
addon_manager.async_install_addon_waiting(),
"Addon install",
"OTBR addon install",
)
if not self.addon_install_task.done():
return self.async_show_progress(
step_id=step_id,
step_id="install_otbr_addon",
progress_action="install_addon",
description_placeholders={
**self._get_translation_placeholders(),
@@ -240,208 +444,50 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally:
self.addon_install_task = None
return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_run_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure the flasher addon to point to the SkyConnect and run it."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(fw_flasher_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, fw_flasher_manager)
if not self.addon_start_task:
async def start_and_wait_until_done() -> None:
await fw_flasher_manager.async_start_addon_waiting()
# Now that the addon is running, wait for it to finish
await fw_flasher_manager.async_wait_until_addon_state(
AddonState.NOT_RUNNING
)
self.addon_start_task = self.hass.async_create_task(
start_and_wait_until_done()
)
if not self.addon_start_task.done():
return self.async_show_progress(
step_id="run_zigbee_flasher_addon",
progress_action="run_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_start_task,
)
try:
await self.addon_start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = fw_flasher_manager.addon_name
self._failed_addon_reason = "addon_start_failed"
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
return self.async_show_progress_done(
next_step_id="uninstall_zigbee_flasher_addon"
)
async def async_step_uninstall_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Uninstall the flasher addon."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
if not self.addon_uninstall_task:
_LOGGER.debug("Uninstalling flasher addon")
self.addon_uninstall_task = self.hass.async_create_task(
fw_flasher_manager.async_uninstall_addon_waiting()
)
if not self.addon_uninstall_task.done():
return self.async_show_progress(
step_id="uninstall_zigbee_flasher_addon",
progress_action="uninstall_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_uninstall_task,
)
try:
await self.addon_uninstall_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
# The uninstall failing isn't critical so we can just continue
finally:
self.addon_uninstall_task = None
return self.async_show_progress_done(next_step_id="confirm_zigbee")
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_pick_firmware_thread(
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(),
)
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_start_otbr_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
return await self._install_addon(
get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon"
)
return self.async_show_progress_done(next_step_id="install_thread_firmware")
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
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": True,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, otbr_manager)
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()
)
@@ -467,7 +513,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally:
self.addon_start_task = None
return self.async_show_progress_done(next_step_id="confirm_otbr")
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pre-confirm OTBR setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_otbr()
async def async_step_confirm_otbr(
self, user_input: dict[str, Any] | None = None
@@ -475,20 +529,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
return self.async_abort(
reason="unsupported_firmware",
if user_input is None:
return self.async_show_form(
step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(),
)
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()
@abstractmethod
def _async_flow_finished(self) -> ConfigFlowResult:

View File

@@ -10,22 +10,6 @@
"pick_firmware_thread": "Thread"
}
},
"install_zigbee_flasher_addon": {
"title": "Installing flasher",
"description": "Installing the Silicon Labs Flasher add-on."
},
"run_zigbee_flasher_addon": {
"title": "Installing Zigbee firmware",
"description": "Installing Zigbee firmware. This will take about a minute."
},
"uninstall_zigbee_flasher_addon": {
"title": "Removing flasher",
"description": "Removing the Silicon Labs Flasher add-on."
},
"zigbee_flasher_failed": {
"title": "Zigbee installation failed",
"description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again."
},
"confirm_zigbee": {
"title": "Zigbee setup complete",
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
@@ -52,12 +36,12 @@
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
},
"progress": {
"install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.",
"run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
"uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed."
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
}
}
},
@@ -110,16 +94,6 @@
"data": {
"disable_multi_pan": "Disable multiprotocol support"
}
},
"install_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"configure_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"start_flasher_addon": {
"title": "Installing firmware",
"description": "Zigbee firmware is now being installed. This will take a few minutes."
}
},
"error": {

View File

@@ -2,15 +2,12 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from yarl import URL
from homeassistant.components.update import (
@@ -20,18 +17,12 @@ from homeassistant.components.update import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.restore_state import ExtraStoredData
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback
from .util import (
ApplicationType,
FirmwareInfo,
guess_firmware_info,
probe_silabs_firmware_info,
)
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
_LOGGER = logging.getLogger(__name__)
@@ -249,19 +240,11 @@ class BaseFirmwareUpdateEntity(
self._attr_update_percentage = round((offset * 100) / total_size)
self.async_write_ha_state()
@asynccontextmanager
async def _temporarily_stop_hardware_owners(
self, device: str
) -> AsyncIterator[None]:
"""Temporarily stop addons and integrations communicating with the device."""
firmware_info = await guess_firmware_info(self.hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(self.hass))
yield
# Switch to an indeterminate progress bar after installation is complete, since
# we probe the firmware after flashing
if offset == total_size:
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
@@ -278,49 +261,18 @@ class BaseFirmwareUpdateEntity(
fw_data = await self.coordinator.client.async_fetch_firmware(
self._latest_firmware
)
fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data)
device = self._current_device
try:
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_type=self.bootloader_reset_type,
progress_callback=self._update_progress,
)
finally:
self._attr_in_progress = False
self.async_write_ha_state()
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=self.bootloader_reset_type,
)
async with self._temporarily_stop_hardware_owners(device):
try:
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=self._update_progress
)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
# Probe the running application type with indeterminate progress
self._attr_update_percentage = None
self.async_write_ha_state()
firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(self.entity_description.expected_firmware_type,),
)
if firmware_info is None:
raise HomeAssistantError(
"Failed to probe the firmware after flashing"
)
self._firmware_info_callback(firmware_info)
finally:
self._attr_in_progress = False
self.async_write_ha_state()
self._firmware_info_callback(firmware_info)

View File

@@ -4,18 +4,20 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Iterable
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
@@ -333,3 +335,52 @@ async def probe_silabs_firmware_type(
return None
return fw_info.firmware_type
async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=bootloader_reset_type,
)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info

View File

@@ -32,6 +32,7 @@ from .const import (
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
NABU_CASA_FIRMWARE_RELEASES_URL,
PID,
PRODUCT,
SERIAL_NUMBER,
@@ -45,19 +46,29 @@ _LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
class TranslationPlaceholderProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders."""
class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
def _get_translation_placeholders(self) -> dict[str, str]:
return {}
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult: ...
else:
# Multiple inheritance with `Protocol` seems to break
TranslationPlaceholderProtocol = object
FirmwareInstallFlowProtocol = object
class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol):
"""Translation placeholder mixin for Home Assistant SkyConnect."""
class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant SkyConnect firmware methods."""
context: ConfigFlowContext
@@ -72,9 +83,35 @@ class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProt
return placeholders
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantSkyConnectConfigFlow(
SkyConnectTranslationMixin,
SkyConnectFirmwareMixin,
firmware_config_flow.BaseFirmwareConfigFlow,
domain=DOMAIN,
):
@@ -207,7 +244,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
class HomeAssistantSkyConnectOptionsFlowHandler(
SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow
SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow
):
"""Zigbee and Thread options flow handlers."""

View File

@@ -48,16 +48,6 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
}
},
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@@ -66,18 +56,6 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -114,15 +92,15 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
}
},
"config": {
@@ -136,22 +114,6 @@
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"uninstall_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -185,15 +147,15 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
}
},
"exceptions": {

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import logging
from typing import Any, final
from typing import TYPE_CHECKING, Any, Protocol, final
import aiohttp
import voluptuous as vol
@@ -31,6 +31,7 @@ from homeassistant.components.homeassistant_hardware.util import (
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
ConfigEntryBaseFlow,
ConfigFlowResult,
OptionsFlow,
)
@@ -41,6 +42,7 @@ from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
NABU_CASA_FIRMWARE_RELEASES_URL,
RADIO_DEVICE,
ZHA_DOMAIN,
ZHA_HW_DISCOVERY_DATA,
@@ -57,8 +59,59 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema(
}
)
if TYPE_CHECKING:
class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
async def _install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult: ...
else:
# Multiple inheritance with `Protocol` seems to break
FirmwareInstallFlowProtocol = object
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantYellowConfigFlow(
YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN
):
"""Handle a config flow for Home Assistant Yellow."""
VERSION = 1
@@ -275,7 +328,9 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
class HomeAssistantYellowOptionsFlowHandler(
BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow
YellowFirmwareMixin,
BaseHomeAssistantYellowOptionsFlow,
BaseFirmwareOptionsFlow,
):
"""Handle a firmware options flow for Home Assistant Yellow."""

View File

@@ -71,16 +71,6 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
}
},
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@@ -89,18 +79,6 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
}
},
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@@ -139,15 +117,15 @@
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device."
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
}
},
"entity": {

View File

@@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import (
async_create_clientsession,
async_get_clientsession,
)
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
_LOGGER,
CONF_COOL_AWAY_TEMPERATURE,
CONF_HEAT_AWAY_TEMPERATURE,
DOMAIN,
)
from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE
UPDATE_LOOP_SLEEP_TIME = 5
PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH]
@@ -56,11 +48,11 @@ async def async_setup_entry(
username = config_entry.data[CONF_USERNAME]
password = config_entry.data[CONF_PASSWORD]
if len(hass.config_entries.async_entries(DOMAIN)) > 1:
session = async_create_clientsession(hass)
else:
session = async_get_clientsession(hass)
# Always create a new session for Honeywell to prevent cookie injection
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
# cookies can still leak into other integrations when using the shared
# session. See issue #147395.
session = async_create_clientsession(hass)
client = aiosomecomfort.AIOSomeComfort(username, password, session=session)
try:
await client.login()

View File

@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
CONF_COOL_AWAY_TEMPERATURE,
@@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
async def is_valid(self, **kwargs) -> bool:
"""Check if login credentials are valid."""
# Always create a new session for Honeywell to prevent cookie injection
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
# cookies can still leak into other integrations when using the shared
# session. See issue #147395.
client = aiosomecomfort.AIOSomeComfort(
kwargs[CONF_USERNAME],
kwargs[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
session=async_create_clientsession(self.hass),
)
await client.login()

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
"requirements": ["AIOSomecomfort==0.0.32"]
"requirements": ["AIOSomecomfort==0.0.33"]
}

View File

@@ -223,7 +223,7 @@ async def async_setup_auth(
# We first start with a string check to avoid parsing query params
# for every request.
elif (
request.method == "GET"
request.method in ["GET", "HEAD"]
and SIGN_QUERY_PARAM in request.query_string
and async_validate_signed_request(request)
):

View File

@@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
@property
def available(self) -> bool:
"""Return the available attribute of the entity."""
return self.entity_description.available_fn(self.mower_attributes)
return super().available and self.entity_description.available_fn(
self.mower_attributes
)
@handle_sending_exception()
async def async_press(self) -> None:

View File

@@ -1,7 +1,19 @@
"""The constants for the Husqvarna Automower integration."""
from aioautomower.model import MowerStates
DOMAIN = "husqvarna_automower"
EXECUTION_TIME_DELAY = 5
NAME = "Husqvarna Automower"
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"
ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
MowerStates.ERROR,
MowerStates.FATAL_ERROR,
MowerStates.OFF,
MowerStates.STOPPED,
MowerStates.WAIT_POWER_UP,
MowerStates.WAIT_UPDATING,
]

View File

@@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .const import DOMAIN
from .const import DOMAIN, ERROR_STATES
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception
@@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
def activity(self) -> LawnMowerActivity:
"""Return the state of the mower."""
mower_attributes = self.mower_attributes
if mower_attributes.mower.state in ERROR_STATES:
return LawnMowerActivity.ERROR
if mower_attributes.mower.state in PAUSED_STATES:
return LawnMowerActivity.PAUSED
if (mower_attributes.mower.state == "RESTRICTED") or (
mower_attributes.mower.activity in DOCKED_ACTIVITIES
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
return LawnMowerActivity.RETURNING
if (
mower_attributes.mower.state is MowerStates.RESTRICTED
or mower_attributes.mower.activity in DOCKED_ACTIVITIES
):
return LawnMowerActivity.DOCKED
if mower_attributes.mower.state in MowerStates.IN_OPERATION:
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
return LawnMowerActivity.RETURNING
return LawnMowerActivity.MOWING
return LawnMowerActivity.ERROR
@property
def available(self) -> bool:
"""Return the available attribute of the entity."""
return (
super().available and self.mower_attributes.mower.state != MowerStates.OFF
)
@property
def work_areas(self) -> dict[int, WorkArea] | None:
"""Return the work areas of the mower."""

View File

@@ -7,13 +7,7 @@ import logging
from operator import attrgetter
from typing import TYPE_CHECKING, Any
from aioautomower.model import (
MowerAttributes,
MowerModes,
MowerStates,
RestrictedReasons,
WorkArea,
)
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AutomowerConfigEntry
from .const import ERROR_STATES
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import (
AutomowerBaseEntity,
@@ -166,15 +161,6 @@ ERROR_KEYS = [
"zone_generator_problem",
]
ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
MowerStates.ERROR,
MowerStates.FATAL_ERROR,
MowerStates.OFF,
MowerStates.STOPPED,
MowerStates.WAIT_POWER_UP,
MowerStates.WAIT_UPDATING,
]
ERROR_KEY_LIST = list(
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])

View File

@@ -15,12 +15,14 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
PLATFORMS = [
Platform.LAWN_MOWER,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
channel_id = entry.data[CONF_CLIENT_ID]
@@ -54,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: HusqvarnaCoordinator = entry.runtime_data

View File

@@ -3,30 +3,31 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from automower_ble.mower import Mower
from bleak import BleakError
from bleak_retry_connector import close_stale_connections_by_address
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import HusqvarnaConfigEntry
SCAN_INTERVAL = timedelta(seconds=60)
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
"""Class to manage fetching data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HusqvarnaConfigEntry,
mower: Mower,
address: str,
channel_id: str,

View File

@@ -10,10 +10,10 @@ from homeassistant.components.lawn_mower import (
LawnMowerEntity,
LawnMowerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HusqvarnaConfigEntry
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
from .entity import HusqvarnaAutomowerBleEntity
@@ -21,11 +21,11 @@ from .entity import HusqvarnaAutomowerBleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HusqvarnaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AutomowerLawnMower integration from a config entry."""
coordinator: HusqvarnaCoordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data
address = coordinator.address
async_add_entities(

View File

@@ -288,8 +288,10 @@ class ImageView(HomeAssistantView):
"""Initialize an image view."""
self.component = component
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
@@ -306,6 +308,31 @@ class ImageView(HomeAssistantView):
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
"""Start a HEAD request.
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
try:
image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -317,7 +344,11 @@ class ImageView(HomeAssistantView):
except (HomeAssistantError, ValueError) as ex:
raise web.HTTPInternalServerError from ex
return web.Response(body=image.content, content_type=image.content_type)
return web.Response(
body=image.content,
content_type=image.content_type,
headers={"Content-Length": str(len(image.content))},
)
async def async_get_still_stream(

View File

@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pynecil"],
"quality_scale": "platinum",
"requirements": ["pynecil==4.1.0"]
"requirements": ["pynecil==4.1.1"]
}

View File

@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/ista_ecotrend",
"iot_class": "cloud_polling",
"loggers": ["pyecotrend_ista"],
"quality_scale": "gold",
"requirements": ["pyecotrend-ista==3.3.1"]
}

View File

@@ -50,14 +50,18 @@ rules:
discovery:
status: exempt
comment: The integration is a web service, there are no discoverable devices.
docs-data-update: todo
docs-examples: todo
docs-data-update: done
docs-examples:
status: done
comment: describes how to use the integration with the statistics dashboard
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices:
status: exempt
comment: changes are very rare (usually takes years)
entity-category:
status: done
comment: The default category is appropriate.
@@ -67,8 +71,12 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
repair-issues:
status: exempt
comment: integration has no repairs
stale-devices:
status: exempt
comment: integration has no stale devices
# Platinum
async-dependency: todo

View File

@@ -108,22 +108,22 @@ def get_statistics(
if monthly_consumptions := get_consumptions(data, value_type):
return [
{
"value": as_number(
get_values_by_type(
consumptions=consumptions,
consumption_type=consumption_type,
).get(
"additionalValue"
if value_type == IstaValueType.ENERGY
else "value"
)
),
"value": as_number(value),
"date": consumptions["date"],
}
for consumptions in monthly_consumptions
if get_values_by_type(
consumptions=consumptions,
consumption_type=consumption_type,
).get("additionalValue" if value_type == IstaValueType.ENERGY else "value")
if (
value := (
consumption := get_values_by_type(
consumptions=consumptions,
consumption_type=consumption_type,
)
).get(
"additionalValue"
if value_type == IstaValueType.ENERGY
and consumption.get("additionalValue") is not None
else "value"
)
)
]
return None

View File

@@ -66,8 +66,7 @@ def _connect_to_address(
) -> dict[str, Any]:
"""Connect to the Jellyfin server."""
result: dict[str, Any] = connection_manager.connect_to_address(url)
if result["State"] != CONNECTION_STATE["ServerSignIn"]:
if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn:
raise CannotConnect
return result

View File

@@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An
self.api_client.jellyfin.sessions
)
if sessions is None:
return {}
sessions_by_id: dict[str, dict[str, Any]] = {
session["Id"]: session
for session in sessions

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["jellyfin_apiclient_python"],
"requirements": ["jellyfin-apiclient-python==1.10.0"],
"single_config_entry": true
"requirements": ["jellyfin-apiclient-python==1.11.0"]
}

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