Compare commits

...

253 Commits

Author SHA1 Message Date
Michael Hansen 3c029fafdb Translate state name in responses, accounting for English fallback 2025-02-04 14:27:36 -06:00
Norbert Rittel 94f6daa09c Make Sonos action descriptions more UI- and translation-friendly (#137356) 2025-02-04 19:26:32 +00:00
Marc Mueller 79a9f3f2c6 Update home-assistant-bluetooth to 1.13.1 (#137350) 2025-02-04 19:22:36 +00:00
Marc Mueller 54751ef0c7 Update led-ble to 1.1.5 (#137347) 2025-02-04 19:59:59 +01:00
Marc Mueller c203307b0d Update yalexs-ble to 2.5.7 (#137345) 2025-02-04 19:59:45 +01:00
Erik Montnemery eb5036854f Improve error handling when supervisor backups are deleted (#137331)
* Improve error handling when supervisor backups are deleted

* Move exception definitions
2025-02-04 19:49:55 +01:00
Glenn Waters 6ff9b0541e Fix incorrect UPB service entity type (#137346) 2025-02-04 12:27:46 -06:00
J. Nick Koston fed36d5756 Bump uiprotect to 7.5.1 (#137343) 2025-02-04 12:24:42 -06:00
Jan Bouwhuis 24ca7d95ac Bump roombapy to 1.9.0 (#137336) 2025-02-04 11:49:10 -06:00
Michael 0895ac6a82 Improve backup file naming in Synology DSM backup agent (#137278)
* improve backup file naming

* use built-in suggested_filename
2025-02-04 18:20:54 +01:00
Glenn Waters f19404991c Bump upb-lib to 0.6.0 (#137339) 2025-02-04 11:20:05 -06:00
kurens 0c56791d94 Added support for One Time Charge Status to Vicare (#135984)
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
Co-authored-by: kurens <migrzyb@users.noreply.github.com>
Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com>
Co-authored-by: Christopher Fenner <Christopher.Fenner@me.com>
2025-02-04 17:16:59 +00:00
Josef Zweck 5dd03c037e Bump onedrive-personal-sdk to 0.0.4 (#137330) 2025-02-04 11:11:55 -06:00
Jan Bouwhuis 1f7d620d6b Don't show active user initiated data entry config flows (#137334)
Do not show active user initiated  data entry config flows
2025-02-04 17:54:05 +01:00
Abílio Costa 9a9374bf45 Add view to download support package to Cloud component (#135856) 2025-02-04 16:52:40 +00:00
Steven B. 2f5816c5b6 Add exception translations to ring integration (#136468)
* Add exception translations to ring integration

* Do not include exception details in exception translations

* Don't check last_update_success for auth errors and update tests

* Do not log errors twice

* Update post review
2025-02-04 09:14:48 -06:00
Erik Montnemery 5629b995ce Include extra metadata in backup WS API (#137296)
* Include extra metadata in backup WS API

* Update onboarding backup view

* Update google_drive tests
2025-02-04 15:57:30 +01:00
Erik Montnemery 345cbc62a7 Minor adjustments of hassio backup tests (#137324) 2025-02-04 14:19:48 +01:00
Glenn Vandeuren (aka Iondependent) a4f0194786 Convert Niko home control to async (#137174) 2025-02-04 14:10:27 +01:00
Erik Montnemery ffc6aa0035 Report progress while restoring supervisor backup (#137313) 2025-02-04 12:55:36 +00:00
Joakim Sørensen 3e45af9995 Bump hass-nabucasa from 0.88.1 to 0.89.0 (#137321) 2025-02-04 13:54:50 +01:00
Marc Mueller cd028f8d21 Update types packages (#137317) 2025-02-04 13:37:38 +01:00
Joakim Sørensen dd1def3c5d Add default voice for languages in cloud TTS (#137300)
* Add default voice for languages in cloud TTS

* Add test

* use defined voice

* Add test to ensure all default voices are valid
2025-02-04 13:32:33 +01:00
Marc Mueller 0a32a9d6db Update attrs to 25.1.0 (#137316) 2025-02-04 12:59:53 +01:00
Duco Sebel d1d498e27d Remove v2 API support for HomeWizard P1 Meter (#137261) 2025-02-04 12:47:50 +01:00
Indu Prakash 9a565885cb Humidifier turn display off for sleep mode (#137133) 2025-02-04 12:46:14 +01:00
Josef Zweck 7f69c689bf Bump onedrive-personal-sdk to 0.0.3 (#137309) 2025-02-04 12:39:00 +01:00
Jan Bouwhuis efc515ff4e Remove legacy color_mode support for legacy mqtt json light (#136996) 2025-02-04 12:34:36 +01:00
Marc Mueller 64a40a3396 Improve frontier_silicon media_player typing (#137080) 2025-02-04 12:25:09 +01:00
Erik Montnemery ca53d97a6d Improve shutdown of _CipherBackupStreamer (#137257)
* Improve shutdown of _CipherBackupStreamer

* Catch the right exception
2025-02-04 12:24:30 +01:00
Norbert Rittel e18062bce4 Improve descriptions of Bluesound actions (#137156) 2025-02-04 12:17:49 +01:00
Marc Mueller 30c0a1492c Update codespell to 2.4.1 (#137312) 2025-02-04 12:16:24 +01:00
Marc Mueller 43b034b8bb Update pyoverkiz to 1.16.0 (#137310) 2025-02-04 12:03:10 +01:00
Marc Mueller b98b38b3f0 Update pytest-aiohttp to 1.1.0 (#137311) 2025-02-04 12:01:09 +01:00
epenet 09cea6ce96 Cleanup runtime warnings in async unit tests (#137308) 2025-02-04 11:44:17 +01:00
Erik Montnemery 650351a7f3 Report progress while creating supervisor backup (#137301)
* Report progress while creating supervisor backup

* Use enum util
2025-02-04 11:36:03 +01:00
epenet c3b40e681d Fix data update coordinator garbage collection (#137299) 2025-02-04 11:20:06 +01:00
Sid 4ce3fa8813 Allow integrations with digits in hassfest QS runtime_data (#136479) 2025-02-04 10:57:02 +01:00
Robert Resch ea3ccc02d7 Bump uv to 0.5.27 (#137297) 2025-02-04 09:20:28 +01:00
epenet 0c55538370 Use runtime_data in faa_delays (#137292) 2025-02-04 09:02:50 +01:00
Brett Adams 6bd3792e9f Bump tesla-fleet-api to 0.9.2 (#137295) 2025-02-04 08:51:13 +01:00
epenet 5e0312ca60 Use HassKey in file_upload (#137294) 2025-02-04 08:45:41 +01:00
epenet 0f57347797 Use runtime_data in fastdotcom (#137293) 2025-02-04 08:44:24 +01:00
Daniel Hjelseth Høyer 82369535c4 Bump pymill to 0.12.3 (#137264)
Mill lib 0.12.3

Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-02-04 08:25:18 +01:00
Michael f9cc3361e3 Don't blow up when a backup doesn't exist on Synology DSM (#136913)
* don't raise while delte not existing backup

* only raise when error ne 408
2025-02-03 17:42:30 -05:00
Jan Stienstra 42cab208d0 Update Jellyfin codeowner (#137270) 2025-02-03 17:38:01 -05:00
Ernst Klamer 7fe89ea329 Add channel sensor to bthome (#137072) 2025-02-03 22:21:58 +00:00
Simone Chemelli 1654c28d74 Pass config_entry as param to Shelly coordinator (#137276)
* Pass config_entry as param

* diff approach
2025-02-03 22:58:50 +01:00
Wouter 6fa87da5bd Add Shelly script events entities (#135979)
* When an event is received from a script component on a shelly device, this event is send to the hass event bus

* Event emitted from a script will be send to the corresponding event entity

* Added tests for the shelly script event

* The event entity for script are now hidden by default

* Forgot to enable script event entities by default for the test

* Made serveral improvement for the shelly script event entity
- Added device name to event entity
- The event entity is now only created when a script has any event types
- The test for this entity now uses snapshots

* Shelly script event entities will not be create for the BLE scanning script and will now be automatically removed when the script no longer exsists

* Changed variable name to avoid confusion with _id

* Removed old const from first implementation and removed _script_event_listeners and used _event_listeners instead to listen for script events
2025-02-03 22:41:39 +02:00
Paulus Schoutsen 649319f4ee Introduce async_add_assistant_content to conversation chat log (#137273)
introduce async_add_assistant_content_without_tools to conversation chat log
2025-02-03 14:27:55 -06:00
Abílio Costa 282560acf8 Allow ignored idasen_desk devices to be set up from the user flow (#137253) 2025-02-03 13:54:09 -06:00
Simone Chemelli 1680adf158 Add device cleanup to Vodafone Station (#116024)
* add device cleanup

* apply review comments

* fix description

* make cleanup automatic

* .

* rework approach based on IQS021 rule

* add initial devices list from registry

* use connections instead of identifiers

* apply review comment

* add some coordinator tests

* one more test

* cleanup tests

* allign tests

* apply review comment

* removed sensor test

* cleanup test

* align test to latest code

* typo

* fix after rebase

* introduce generic helper

* apply some review comments

* add comments to clarify design

* apply latest review comment

* ruff

* improved coverage

* more coverage

* 100% helpers.py test coverage

* improve test

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2025-02-03 20:48:50 +01:00
Marc Mueller 5a14409dda Update tqdm to 4.67.1 (#137241) 2025-02-03 19:37:38 +01:00
Bram Kragten 3bfc1a87c8 Update frontend to 20250203.0 (#137263) 2025-02-03 19:37:12 +01:00
Michael Hansen 28edbdc107 Clear extra system prompt on start_conversation error (#137254)
* Clear extra system prompt on start_conversation error

* Update homeassistant/components/assist_satellite/entity.py

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-02-03 12:07:45 -05:00
Erik Montnemery 58b7be7c2f Check for errors when creating backups using supervisor (#137220)
* Check for errors when creating backups using supervisor

* Improve error reporting when there's no backup reference
2025-02-03 17:33:03 +01:00
Josef Zweck a41566611e Bump onedrive-personal-sdk to 0.0.2 (#137252) 2025-02-03 17:30:27 +01:00
Martin Hjelmare b660703117 Fix eheimdigital sw_version mock (#137255) 2025-02-03 17:28:54 +01:00
starkillerOG c5e60045b4 Add Smart Rollos virtual motionblinds integration (#137190) 2025-02-03 17:21:28 +01:00
starkillerOG ce5be8686a Add Heicko virtual motionblinds integration (#137191) 2025-02-03 17:18:30 +01:00
starkillerOG 94daeffe44 Add Ublockout virtual integration of MotionBlinds (#137179) 2025-02-03 17:10:39 +01:00
Aaron Godfrey 9856340a33 Bump todist-api-python to 2.1.7 (#136549)
Co-authored-by: Allen Porter <allen@thebends.org>
Co-authored-by: J. Diego Rodríguez Royo <jdrr1998@hotmail.com>
2025-02-03 17:06:21 +01:00
Jan Bouwhuis 30af9057d1 Ensure random temp dir is used during MQTT CI tests (#137221) 2025-02-03 16:06:02 +00:00
Regev Brody a5eda3faf1 Bump python-roborock to 2.11.1 (#137244) 2025-02-03 17:00:36 +01:00
Shay Levy 2682f4a323 Add tests for Shelly Flood gen4 (#137246) 2025-02-03 17:34:02 +02:00
Josef Zweck 628e1ffb84 Migrate OneDrive to onedrive_personal_sdk library (#137064) 2025-02-03 16:25:58 +01:00
Paulus Schoutsen 05ca80f4ba Assist Pipeline to use ChatSession for conversation ID (#137143)
* Assist Pipeline to use ChatSession for conversation ID

* Adjust to latest changes
2025-02-03 09:18:15 -06:00
Paulus Schoutsen 8acab6c646 Assist Satellite to use ChatSession for conversation ID (#137142)
* Assist Satellite to use ChatSession for conversation ID

* Adjust for changes main branch

* Ensure the initial message is in the chat log
2025-02-03 09:13:09 -06:00
Joost Lekkerkerker 4531a46557 Bump python-homeassistant-analytics to 0.9.0 (#137240) 2025-02-03 16:03:13 +01:00
cdnninja 37461d727a Migrate unique ID in vesync switches (#137099) 2025-02-03 15:44:49 +01:00
Marc Mueller b5662ded2c Update pylint-per-file-ignores to 1.4.0 (#137242) 2025-02-03 15:42:21 +01:00
Richard Kroegel 71e28a4af3 Add service to retrieve schedule configuration (#121904) 2025-02-03 14:41:25 +00:00
Marc Mueller dba4637aa9 Update pytest-github-actions-annotate-failures to 0.3.0 (#137243) 2025-02-03 15:40:38 +01:00
Marc Mueller e24564147d Update pytest-asyncio to 0.25.3 (#137231) 2025-02-03 14:52:56 +01:00
Marc Mueller 9bc110104d Update pyOpenSSL to 25.0.0 (#137236) 2025-02-03 14:46:49 +01:00
Marc Mueller c903658aa8 Update syrupy to 4.8.1 (#137235) 2025-02-03 14:46:22 +01:00
Conor Eager 34a229af52 Add Starlink connectivity binary sensor (#133184)
Co-authored-by: David Rapan <david@rapan.cz>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-02-03 14:34:25 +01:00
Marc Mueller 8579456895 Update pytest-picked to 0.5.1 (#137233) 2025-02-03 14:25:06 +01:00
Marc Mueller 1d7e485aa3 Update pytest-freezer to 0.4.9 (#137232) 2025-02-03 14:11:03 +01:00
TimL 0e73363d04 Bump pysmlight to v0.2.2 (#137218) 2025-02-03 14:06:27 +01:00
Markus Adrario 48184e742a Fix minor issues in Homee (#137239) 2025-02-03 14:05:51 +01:00
RJPoelstra 0034055ac8 Fix retrieving PIN when no pin is set on mount in motionmount integration (#137230) 2025-02-03 14:05:11 +01:00
Marc Mueller 52d7cfbe32 Update coverage to 7.6.10 (#137229) 2025-02-03 14:03:41 +01:00
Marc Mueller a9e73d9253 Update pylint to 3.3.4 (#137227) 2025-02-03 14:01:20 +01:00
Marc Mueller a5c01a4d4f Update pipdeptree to 2.25.0 (#137228) 2025-02-03 14:01:04 +01:00
Marc Mueller 6d31530811 Update license-expression to 30.4.1 (#137226) 2025-02-03 14:00:16 +01:00
Markus Jacobsen c950c69cb3 Add parallel updates setting to Bang & Olufsen Event platform (#135850) 2025-02-03 13:42:47 +01:00
Norbert Rittel c2f94542aa Fix uppercase / lowercase setup strings in Generic Camera (#137219) 2025-02-03 12:38:38 +00:00
Simone Chemelli cce6c735ad Add support for Shelly Flood gen4 (#136981) 2025-02-03 13:04:14 +01:00
Erik Montnemery 9cfe109210 Check for errors when restoring backups using supervisor (#137217)
* Check for errors when restoring backups using supervisor

* Break long line in test

* Improve comments
2025-02-03 11:51:29 +01:00
Norbert Rittel 0b2b222fca Fixes in user-facing strings of Tado integration (#137158) 2025-02-03 10:54:32 +01:00
Norbert Rittel d2092315f5 Fix spelling of "SharkClean" and sentence-casing of some words (#137183) 2025-02-03 09:06:51 +01:00
cdnninja d18fb4e6f9 Vesync bump pyvesync library (#137208) 2025-02-03 08:58:33 +01:00
Andre Lengwenus 00e0a5bc10 Bump pypck to 0.8.5 (#137176) 2025-02-03 08:26:08 +01:00
Paulus Schoutsen 9679fc7878 Chat session rev2 (#137209)
* Chat Session rev 2

* Rename session to chat_log

* Simplify typing

* Typing

* Address comments

* Fix anthropic and ollama
2025-02-03 00:05:20 -05:00
J. Nick Koston ce93cb9467 Bump dbus-fast to 2.23.0 (#137205)
changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.31.0...v2.32.0
2025-02-02 21:22:58 -05:00
J. Nick Koston 1860794cac Bump bleak-esphome to 2.7.0 (#137199)
changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.6.0...v2.7.0
2025-02-02 21:22:49 -05:00
TimL f846aa4705 Simplify config entry title for SMLIGHT (#137206) 2025-02-02 17:46:27 -06:00
TimL 0f641fcb74 Switch to using IP Addresses for connecting to smlight devices (#137204) 2025-02-02 17:08:32 -06:00
Denis Shulyaka 0f36759a38 Add support for OpenAI reasoning models (#137139)
* Add support for OpenAI reasoning models

* Apply suggestions from code review

* Remove o1-mini* and o1-preview* model support

* List unsupported models

* Reenable audio models (they also support text)
2025-02-02 16:55:16 -05:00
starkillerOG a6781107df Add Linx virtual motionblinds integration (#137184) 2025-02-02 21:22:04 +01:00
J. Nick Koston 6afaeee0fd Bump aiodhcpwatcher to 1.0.3 (#137188)
changelog: https://github.com/bdraco/aiodhcpwatcher/compare/v1.0.2...v1.0.3
2025-02-02 21:17:58 +02:00
J. Nick Koston 1a394876b1 Bump dbus-fast to 2.31.0 (#137180)
changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.30.4...v2.31.0
2025-02-02 12:10:24 -05:00
TimL a98109614e Allow manual smlight user setup to override discovery (#137136)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-02-02 10:37:08 -06:00
J. Nick Koston a3d0ec4e6e Bump bluetooth-data-tools to 1.23.3 (#137147) 2025-02-02 10:25:59 -06:00
Jan Bouwhuis 839e2881e0 Fix mqtt reconfigure does not use broker entry password when it is not changed (#137169) 2025-02-02 16:21:40 +01:00
Steven B. cb3ed506ad Bump python-kasa to 0.10.1 (#137173) 2025-02-02 17:19:31 +02:00
Brett Adams 9d808a7b5a Bump teslemetry-stream to 0.6.10 (#137159)
* bump

* v0.6.10
2025-02-02 14:29:33 +01:00
Jeef b8237eaa55 Bump monarchmoney to 0.4.4 (#137168)
feat: update to backing lib to update backing lib
2025-02-02 14:11:44 +01:00
starkillerOG 9c747113a2 Reolink styling using walrus operator (#137069) 2025-02-02 13:18:36 +01:00
Indu Prakash 634e1dd9eb fix: sort available modes (#137134) 2025-02-02 10:11:40 +02:00
J. Nick Koston 9fcaf32c9c Bump dbus-fast to 2.30.4 (#137151)
changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.30.2...v2.30.4
2025-02-02 10:09:52 +02:00
Manu d55a6de01b Bump habiticalib to v0.3.4 (#137148)
Bump habiticalib to version 0.3.4
2025-02-02 10:08:14 +02:00
Paulus Schoutsen dd9bd8ef73 Make get_chat_session a callback context manager (#137146) 2025-02-01 23:37:24 -05:00
Martin Hjelmare 2ce585463c Fix home connect manifest logger (#137138) 2025-02-01 21:03:54 -05:00
Robert Resch f9df5b413b Bump deebot-client to 12.0.0b0 (#137137) 2025-02-01 21:02:34 -05:00
J. Nick Koston 39a575dd29 Add missing brackets to ESPHome configuration URLs with IPv6 addresses (#137132)
fixes #137125
2025-02-01 21:02:10 -05:00
Denis Shulyaka 27f89f7710 Bump openai to 1.61.0 (#137130) 2025-02-01 21:01:41 -05:00
Paulus Schoutsen 2f6640707b Extract conversation ID generation to helper (#137062)
* Extract conversation ID generation to helper

* Allow nested get_chat_log calls
2025-02-01 20:54:00 -05:00
J. Diego Rodríguez Royo 30314ca32b Add and delete Home Connect devices on CONNECTED/PAIRED and DEPAIRED events (#136952)
* Add and delete devices on CONNECT/PAIRED and DEPAIRED events

* Simplify device depairing

* small fixes

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add always the devices

* kind of revert changes

to simplify the entity fetch and removing on connected/paired and depaired

* cache `ha_id`

* Fix typo

* Remove unnecessary device info at HomeConnectEntity

* Move common code of each platform to `common.py`

* Added docstring to clarify usage

* Apply suggestions

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-02 02:02:45 +01:00
J. Diego Rodríguez Royo 147b5f549f Fetch current active and selected programs at Home Connect (#136948)
* Fetch current active and selected programs

* Intialize HomeConnectEntity first at SelectProgramEntity

* Use the right exception

* Use active/selected program from `get_all_programs`

This will allow us to reduce the number of requests that we need to perform to get all the data ready (only one requests vs. three requests)

* Remove no longer required mocks

* Fix
2025-02-02 00:12:26 +01:00
Allen Porter bf6f790d09 Remove entity state from mcp-server prompt (#137126)
* Create a stateless assist API for MCP server

* Update stateless API

* Fix areas in exposed entity fields

* Add tests that verify areas are returned

* Revert the getstate intent

* Revert whitespace change

* Revert whitespace change

* Revert method name changes to avoid breaking openai and google tests
2025-02-01 14:26:52 -08:00
J. Nick Koston 2c99e3778e Bump habluetooth to 3.21.0 (#137129) 2025-02-01 15:56:28 -06:00
Alex Thompson 51c16cc808 Allow ignored tilt_ble devices to be set up from user flow (#137123)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-02-01 15:09:49 -06:00
Markus Adrario f5fd49d8cb Small additions for Homee (#137000)
* fix entity set value error handling

* Translation for node_state sensor

* add entrance gate operator to covers

* fix review comments

* Update tests/components/homee/test_cover.py

* Delete Logging statement

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-02-01 14:11:53 -06:00
Shay Levy ba427a1054 Allow ignored Aranet devices to be set up from the user flow (#137121) 2025-02-01 14:03:19 -06:00
Marc Mueller 95bcbd2c4f Improve fully_kiosk sensor typing (#137079) 2025-02-01 14:00:00 -06:00
Martin Hjelmare c35cd6fb76 Bump aiohomeconnect to 0.12.3 (#137085) 2025-02-01 21:22:57 +02:00
J. Nick Koston 3b69a2bbd1 Allow ignored airthings_ble devices to be set up from the user flow (#137102)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for airthings
2025-02-01 21:22:13 +02:00
J. Nick Koston d402166d1d Allow ignored yale_ble devices to be set up from the user flow (#137103)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for yalexs_ble
2025-02-01 21:21:53 +02:00
J. Nick Koston 9f85756785 Allow ignored thermopro devices to be set up from the user flow (#137104)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for thermopro
2025-02-01 21:21:43 +02:00
J. Nick Koston d28a4258a3 Allow ignored inkbird devices to be set up from the user flow (#137106)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for inkbird
2025-02-01 21:21:21 +02:00
J. Nick Koston caaa7def2f Allow ignored mopeka devices to be set up from the user flow (#137107)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for mopeka
2025-02-01 21:21:09 +02:00
J. Nick Koston bfb9de46fe Allow ignored oralb devices to be set up from the user flow (#137109)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for oralb
2025-02-01 21:20:52 +02:00
J. Nick Koston ced52f64b4 Allow ignored qingping devices to be set up from the user flow (#137111)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for qingping
2025-02-01 13:19:44 -06:00
J. Nick Koston 5967957e0b Allow ignored sensorpush devices to be set up from the user flow (#137113)
Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent.

Allow ignored devices to be selected in the user step and replace the ignored entry.

Same as #137056 and #137052 but for sensorpush
2025-02-01 21:19:42 +02:00
J. Nick Koston 2888c64da9 Allow ignored xiaomi_ble devices to be set up from the user flow (#137115) 2025-02-01 13:16:39 -06:00
Ілля Піскурьов 4cab773bab Enable Modbus Climate / HVAC on/off to use the coil instead of the register(s) (#135657) 2025-02-01 13:15:20 -06:00
J. Nick Koston d3da3b3470 Allow ignored bthome devices to be set up from the user flow (#137105) 2025-02-01 13:08:24 -06:00
Assaf Inbal 9c4940e915 Fix Homekit camera profiles schema (#137110) 2025-02-01 12:49:09 -06:00
J. Nick Koston d43083e2f9 Set via_device for remote Bluetooth adapters to link to the parent device (#137091) 2025-02-01 12:10:59 -06:00
Marc Mueller 1157a08f72 Improve isy994 sensor typing (#137077) 2025-02-01 16:48:09 +01:00
IceBotYT 278c35f830 Bump lacrosse-view to 1.0.4 (#137058) 2025-02-01 09:16:10 -06:00
Marc Mueller f29b4134d2 Update RestrictedPython to 8.0 (#137075) 2025-02-01 09:15:55 -06:00
Marc Mueller da7ba85ee6 Improve sonos alarms typing (#137078) 2025-02-01 14:48:46 +01:00
J. Nick Koston 37daa57818 Bump habluetooth to 3.20.1 (#137063) 2025-02-01 07:26:31 -06:00
Nathan Spencer ee37bc476f Raise HomeAssistantError from camera snapshot service (#137051)
* Raise HomeAssistantError from camera snapshot service

* Improve error message

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-01 13:53:04 +01:00
tronikos d4586fb2e4 Test config_entry_oauth2_flow.async_get_redirect_uri (#136976)
* Test config_entry_oauth2_flow.async_get_redirect_uri

* review
2025-02-01 12:49:18 +01:00
J. Diego Rodríguez Royo 63ab13681a Home Connect entities availability based on the connected state of the appliance (#136951)
* Base the entity availability on the connected state of the appliance

* cache `ha_id`

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Inlcude coordinator `available` property at entity

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-01 12:46:49 +01:00
J. Diego Rodríguez Royo efcfd97d1b Filter programs by execution type at select program entities at Home Connect (#136950)
* Filter programs by execution type at select program entities

* Suggestions and improvements

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Use function and translation key at select program entity description

* Fix select entity description docstring

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-01 12:06:39 +01:00
Jan-Philipp Benecke 889fe05a48 Load hassio before backup at frontend stage (#137067) 2025-02-01 11:43:45 +01:00
Norbert Rittel 123cd92986 Replace keys with translatable friendly names in Statistics helper (#136936) 2025-02-01 10:48:05 +01:00
J. Diego Rodríguez Royo 285a0a6c81 Fix Home Connect actions keys (#137027)
* Fix actions

* Use coerce

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-01 10:47:27 +01:00
Joris Pelgröm 012f7112d7 Add switch platform to LetPot integration (#136383)
* Add switch platform to LetPot integration

* deviceclient -> device_client

* Remove coordinator data None check

* Add exception handling + test
2025-02-01 08:15:36 +01:00
tronikos bb61e31298 For consistency use suggested_filename in Google Drive (#137061)
Use  suggested_filename in Google Drive
2025-01-31 21:40:52 -08:00
TimL 9453b925cd Bump pysmlight to v0.2.1 (#137053) 2025-01-31 20:39:28 -06:00
J. Nick Koston 64d2f84c0d Allow ignored switchbot devices to be set up from the user flow (#137056) 2025-01-31 20:25:16 -06:00
J. Nick Koston 84e15e10ef Allow ignored govee-ble devices to be set up from the user flow (#137052)
* Allow ignored govee-ble devices to be setup up from the user flow

Every few days we get an issue report about a device
a user ignored and forgot about, and than can no longer
get set up. Allow ignored devices to be selected in
the user step and replace the ignored entry.

* Add the ability to skip ignored config entries when calling _abort_if_unique_id_configured

see https://github.com/home-assistant/core/pull/137052

* coverage

* revert
2025-01-31 21:24:01 -05:00
Abílio Costa 5da9bfe0e3 Add dev docs and frontend PR links to PR template (#137034) 2025-01-31 20:03:20 -05:00
Jan Bouwhuis e56772d37b Bump aioimaplib to version 2.0.1 (#137049) 2025-01-31 18:38:11 -06:00
J. Nick Koston c35e7715b7 Bump habluetooth to 3.17.1 (#137045) 2025-01-31 18:13:27 -06:00
Norbert Rittel 7040614433 Fix one occurrence of "api" to match all other in sensibo and HA (#137037) 2025-02-01 00:56:45 +02:00
J. Nick Koston 5fa5bd1302 Bump aiohttp-asyncmdnsresolver to 0.0.3 (#137040) 2025-01-31 16:30:20 -06:00
J. Nick Koston dc7f445356 Bump bthome-ble to 3.12.3 (#137036) 2025-01-31 15:18:19 -06:00
J. Nick Koston 7a0400154e Bump zeroconf to 0.143.0 (#137035) 2025-01-31 15:00:39 -06:00
Joost Lekkerkerker d51e72cd95 Update Overseerr string to mention CSRF (#137001)
* Update Overseerr string to mention CSRF

* Update homeassistant/components/overseerr/strings.json

* Update homeassistant/components/overseerr/strings.json

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-01-31 22:29:31 +02:00
Joris Pelgröm 7103ea7e8f Add exception handling for updating LetPot time entities (#137033)
* Handle exceptions for entity edits for LetPot

* Set exception-translations: done
2025-01-31 22:28:23 +02:00
Ernst Klamer 164d38ac0d Bump bthome-ble to 3.11.0 (#137032)
bump bthome-ble to 3.11.0
2025-01-31 22:03:17 +02:00
Josef Zweck 4a2e9db9fe Use readable backup names for onedrive (#137031)
* Use readable names for onedrive

* ensure filename is fixed

* fix import
2025-01-31 20:59:34 +01:00
Robert Resch df166d178c Bump deebot-client to 11.1.0b2 (#137030) 2025-01-31 14:17:14 -05:00
J. Nick Koston f75a61ac90 Bump SQLAlchemy to 2.0.37 (#137028)
changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37

There is a bug fix that likely affects us that could lead to corrupted queries
https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd
2025-01-31 13:52:38 -05:00
starkillerOG 92dd18a9be Ensure Reolink can start when privacy mode is enabled (#136514)
* Allow startup when privacy mode is enabled

* Add tests

* remove duplicate privacy_mode

* fix tests

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* Store in subfolder and cleanup when removed

* Add tests and fixes

* fix styling

* rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE

* use helper store

---------

Co-authored-by: Robert Resch <robert@resch.dev>
2025-01-31 13:48:47 -05:00
Allen Porter df59b1d4fa Persist roborock maps to disk only on shutdown (#136889)
* Persist roborock maps to disk only on shutdown

* Rename on_unload to on_stop

* Spawn 1 executor thread and block writes to disk

* Update tests/components/roborock/test_image.py

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

* Use config entry setup instead of component setup

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-01-31 13:45:01 -05:00
Martin Hjelmare 9bc3c417ae Add codeowner to Home Connect (#137029) 2025-01-31 12:36:40 -06:00
Erik Montnemery 065cdf421f Delete old addon update backups when updating addon (#136977)
* Delete old addon update backups when updating addon

* Address review comments

* Add tests
2025-01-31 13:33:48 -05:00
Bram Kragten 256157d413 Update frontend to 20250131.0 (#137024) 2025-01-31 19:25:24 +01:00
J. Nick Koston f8f12957b5 Bump bleak-esphome to 2.6.0 (#137025) 2025-01-31 12:15:31 -06:00
J. Nick Koston c4cb94bddd Bump habluetooth to 3.17.0 (#137022) 2025-01-31 11:29:44 -06:00
Erik Montnemery 64f679ba8f Make supervisor backup file names more user friendly (#137020) 2025-01-31 18:20:30 +01:00
Duco Sebel e0bf248867 Bumb python-homewizard-energy to 8.3.2 (#136995) 2025-01-31 10:49:25 -06:00
Nathan Spencer b1c3d0857a Add pets to litterrobot integration (#136865) 2025-01-31 17:35:08 +01:00
Erik Montnemery e18dc063ba Make backup file names more user friendly (#136928)
* Make backup file names more user friendly

* Strip backup name

* Strip backup name

* Underscores
2025-01-31 17:33:30 +01:00
Joris Pelgröm b85b834bdc Bump letpot to 0.4.0 (#137007)
* Bump letpot to 0.4.0

* Fix test item
2025-01-31 10:31:31 -06:00
RJPoelstra f5924146c1 Add data_description's to motionmount integration (#137014)
* Add data_description's

* Use more common terminology
2025-01-31 10:29:59 -06:00
Norbert Rittel fafeedd01b Revert previous PR and remove URL from error message instead (#137018) 2025-01-31 10:26:43 -06:00
Erik Montnemery 64814e086f Make sure we load the backup integration before frontend (#137010) 2025-01-31 15:50:30 +00:00
Joost Lekkerkerker 6f1539f60d Use device name as entity name in Eheim digital climate (#136997) 2025-01-31 15:32:11 +00:00
Jakob Schlyter 84ae476b67 Energy distance units (#136933)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-01-31 14:22:25 +00:00
Josef Zweck 21ffcf853b Call backup listener during setup in onedrive (#136990) 2025-01-31 13:39:59 +01:00
RJPoelstra d4a355e684 Bump python-MotionMount to 2.3.0 (#136985) 2025-01-31 13:29:07 +01:00
Manu 0773e37dab Create/delete lists at runtime in Bring integration (#130098) 2025-01-31 13:23:44 +01:00
Cyrill Raccaud 8eb9cc0e8e Remove the unparsed config flow error from Swiss public transport (#136998) 2025-01-31 13:19:04 +01:00
RJPoelstra b702d88ab7 Use runtime_data in motionmount integration (#136999) 2025-01-31 13:17:22 +01:00
starkillerOG 66f048f49f Make Reolink reboot button always available (#136667) 2025-01-31 13:15:22 +01:00
Steven B. c7041a97be Do not duplicate device class translations in ring integration (#136868) 2025-01-31 13:03:13 +01:00
Josef Zweck f21ab24b8b Add sensors for drink stats per key to lamarzocco (#136582)
* Add sensors for drink stats per key to lamarzocco

* Add icon

* Use UOM translations

* fix tests

* remove translation key

* Update sensor.py

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

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-01-31 12:55:51 +01:00
Sid cde59613a5 Refactor eheimdigital platform async_setup_entry (#136745) 2025-01-31 12:52:17 +01:00
Christopher Fenner d83c335ed6 Add support for standby quickmode to ViCare integration (#133156) 2025-01-31 12:45:58 +01:00
Andrew Jackson 50f3d79fb2 Add post action to mastodon (#134788)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-01-31 12:29:23 +01:00
Jan Stienstra a7903d344f Bump jellyfin-apiclient-python to 1.10.0 (#136872) 2025-01-31 12:29:00 +01:00
Gerben Jongerius 010cad08c0 Add tariff sensor and peak sensors (#136919) 2025-01-31 12:12:07 +01:00
Cyrill Raccaud e512ad7a81 Fix missing duration translation for Swiss public transport integration (#136982) 2025-01-31 12:10:44 +01:00
Markus Adrario e578327054 Add more Homee cover tests (#136568) 2025-01-31 11:46:12 +01:00
Josef Zweck 230e101ee4 Retry backup uploads in onedrive (#136980)
* Retry backup uploads in onedrive

* no exponential backup on timeout
2025-01-31 11:23:33 +01:00
Abílio Costa 3fb70316da Fix error messaging for cascading service calls (#136966) 2025-01-31 11:10:57 +01:00
Avi Miller ab5583ed40 Suppress color_temp warning if color_temp_kelvin is provided (#136884) 2025-01-31 10:55:42 +01:00
Norbert Rittel f1c720606f Fixes to the user-facing strings of energenie_power_sockets (#136844) 2025-01-31 10:38:30 +01:00
Austin Mroczek 270108e8e4 Bump total-connect-client to 2025.1.4 (#136793) 2025-01-31 10:36:06 +01:00
J. Nick Koston fc979cd564 Bump habluetooth to 3.15.0 (#136973) 2025-01-31 08:34:39 +01:00
tronikos 99e307fe5a Bump opower to 0.8.9 (#136911)
* Bump opower to 0.8.9

* mypy
2025-01-30 23:33:58 -08:00
J. Diego Rodríguez Royo 4d4e11a0eb Fetch all programs instead of only the available ones at Home Connect (#136949)
Fetch all programs instead of only the available ones
2025-01-31 08:26:57 +01:00
Shay Levy 4613087e86 Add serial number to LG webOS TV device info (#136968) 2025-01-31 08:23:03 +01:00
tronikos 6c93d6a2d0 Include the redirect URL in the Google Drive instructions (#136906)
* Include the redirect URL in the Google Drive instructions

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-01-30 22:59:03 -08:00
Norbert Rittel f93b1cc950 Make assist_satellite action descriptions consistent (#136955)
- use third-person singular for descriptive language, following HA standards
- use "a satellite" in both descriptions to match
- use sentence-casing for "Start conversation" action name
2025-01-30 16:03:56 -06:00
Michael Hansen 00f8afe332 Consume extra system prompt in first pipeline (#136958) 2025-01-30 17:01:24 -05:00
Matthias Alphart ea496290c2 Update knx-frontend to 2025.1.30.194235 (#136954) 2025-01-30 21:59:00 +01:00
epenet acb3f4ed78 Add software version to onewire device info (#136934) 2025-01-30 21:03:47 +01:00
J. Nick Koston b12598d963 Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) 2025-01-30 20:38:27 +01:00
J. Nick Koston cf737356fd Bump zeroconf to 0.142.0 (#136940)
changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0
2025-01-30 12:55:14 -06:00
Bram Kragten 6858f2a3d2 Update frontend to 20250130.0 (#136937) 2025-01-30 18:38:11 +01:00
Joost Lekkerkerker c3b0bc3e0d Show name of the backup agents in issue (#136925)
* Show name of the backup agents in issue

* Show name of the backup agents in issue

* Update homeassistant/components/backup/manager.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-01-30 18:15:54 +01:00
tronikos 3dc52774fc Don't log errors when raising a backup exception in Google Drive (#136916) 2025-01-30 18:15:13 +01:00
Maciej Bieniek f501b55aed Fix KeyError for Shelly virtual number component (#136932) 2025-01-30 18:43:48 +02:00
moritzthecat eca93f1f4e Add DS2450 to onewire integration (#136882)
* add DS2450 to onewire integration

* added tests for DS2450 in const.py

* Update homeassistant/components/onewire/sensor.py

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

* spelling change voltage -> Voltage

* use translation key

* tests run after en.json edited

* Update homeassistant/components/onewire/strings.json

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

* naming convention adapted

* Update homeassistant/components/onewire/sensor.py

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

* adatpt owfs namings to HA namings. volt -> voltage

* Apply suggestions from code review

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-01-30 17:33:41 +01:00
Erik Montnemery ec53b08e09 Don't blow up when a backup doesn't exist on supervisor (#136907) 2025-01-30 17:32:01 +01:00
Josef Zweck 63af407f8f Pick onedrive owner from a more reliable source (#136929)
* Pick onedrive owner from a more reliable source

* fix
2025-01-30 17:08:35 +01:00
Michael 6dd2d46328 Fix backup related translations in Synology DSM (#136931)
refernce backup related strings in option-flow strings
2025-01-30 16:59:39 +01:00
Jan Bouwhuis 8db6a6cf17 Shorten the integration name for incomfort (#136930) 2025-01-30 17:47:09 +02:00
Josef Zweck d148bd9b0c Fix onedrive does not fail on delete not found (#136910)
* Fix onedrive does not fail on delete not found

* Fix onedrive does not fail on delete not found
2025-01-30 16:33:59 +01:00
Joost Lekkerkerker 773375e7b0 Fix Sonos importing deprecating constant (#136926) 2025-01-30 16:16:39 +01:00
Joost Lekkerkerker 232e99b62e Create Xbox signed session in executor (#136927) 2025-01-30 16:16:22 +01:00
Erik Montnemery bab616fa61 Fix handling of renamed backup files in the core writer (#136898)
* Fix handling of renamed backup files in the core writer

* Adjust mocking

* Raise BackupAgentError instead of KeyError in get_backup_path

* Add specific error indicating backup not found

* Fix tests

* Ensure backups are loaded

* Fix tests
2025-01-30 15:25:16 +01:00
Maciej Bieniek 1c4ddb36d5 Convert valve position to int for Shelly BLU TRV (#136912) 2025-01-30 15:16:51 +02:00
Duco Sebel 76570b5144 Remove stale translation string in HomeWizard (#136917)
Remove stale translation in HomeWizard
2025-01-30 14:47:33 +02:00
epenet 5dd147e83b Add missing discovery string from onewire (#136892) 2025-01-30 11:46:27 +01:00
TimL 9eb383f314 Bump Pysmlight to v0.2.0 (#136886)
* Bump pysmlight to v0.2.0

* Update info.json fixture with radios list

* Update diagnostics snapshot
2025-01-30 12:11:40 +02:00
Erik Montnemery 52feeedd2b Poll supervisor job state when creating or restoring a backup (#136891)
* Poll supervisor job state when creating or restoring a backup

* Update tests

* Add tests for create and restore jobs finishing early
2025-01-30 11:09:31 +01:00
Erik Montnemery 1b5316b269 Ignore dangling symlinks when restoring backup (#136893) 2025-01-30 11:09:07 +01:00
Allen Porter 708ae09c7a Bump nest to 7.1.1 (#136888) 2025-01-30 11:07:55 +02:00
Arie Catsman 97fcbed6e0 Add error handling to enphase_envoy switch platform action (#136837)
* Add error handling to enphase_envoy switch platform action

* Use decorators for exception handling
2025-01-30 11:07:10 +02:00
dependabot[bot] a8175b785f Bump github/codeql-action from 3.28.6 to 3.28.8 (#136890)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.6 to 3.28.8.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3.28.6...v3.28.8)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-30 08:42:23 +01:00
TheJulianJES 64b056fbe9 Bump ZHA to 0.0.47 (#136883) 2025-01-29 20:57:36 -06:00
Paulus Schoutsen 427c437a68 Add start_conversation service to Assist Satellite (#134921)
* Add start_conversation service to Assist Satellite

* Fix tests

* Implement start_conversation in voip

* Update homeassistant/components/assist_satellite/entity.py

---------

Co-authored-by: Michael Hansen <mike@rhasspy.org>
2025-01-29 21:32:10 -05:00
J. Diego Rodríguez Royo b637129208 Migrate from homeconnect dependency to aiohomeconnect (#136116)
* Migrate from homeconnect dependency to aiohomeconnect

* Reload the integration if there is an API error on event stream

* fix typos at coordinator tests

* Setup config entry at coordinator tests

* fix ruff

* Bump aiohomeconnect to version 0.11.4

* Fix set program options

* Use context based updates at coordinator

* Improved how `context_callbacks` cache is invalidated

* fix

* fixes and improvements at coordinator

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove stale Entity inheritance

* Small improvement for light subscriptions

* Remove non-needed function

It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor

* Static methods and variables at conftest

* Refresh the data after an event stream interruption

* Cleaned debug logs

* Fetch programs at coordinator

* Improvements

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Simplify obtaining power settings from coordinator data

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove unnecessary statement

* use `is UNDEFINED` instead of `isinstance`

* Request power setting only when it is strictly necessary

* Bump aiohomeconnect to 0.12.1

* use raw keys for diagnostics

* Use keyword arguments where needed

* Remove unnecessary statements

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-01-30 02:42:41 +01:00
TimL 4e3e1e91b7 Fix loading of SMLIGHT integration when no internet is available (#136497)
* Don't fail to load integration if internet unavailable

* Add test case for no internet

* Also test we recover after internet returns
2025-01-30 01:01:39 +00:00
Artur Pragacz 4066289662 Update quality scale in Onkyo (#136710) 2025-01-29 22:32:16 +00:00
Erik Montnemery aca9607e2f Bump backup store to version 1.3 (#136870)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-01-29 14:58:06 -06:00
J. Nick Koston edabf0f8dd Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) 2025-01-29 13:09:00 -06:00
Erik Montnemery 5286bd8f0c Persist hassio backup restore status after core restart (#136857)
* Persist hassio backup restore status after core restart

* Remove useless condition
2025-01-29 13:55:02 -05:00
Michael Hansen d206553a0d Cancel call if user does not pick up (#136858) 2025-01-29 13:52:32 -05:00
Abílio Costa b500fde468 Handle locked account error in Whirlpool (#136861) 2025-01-29 13:51:09 -05:00
Bram Kragten 46cef2986c Bump version to 2025.3.0 (#136859) 2025-01-29 19:32:36 +01:00
Erik Montnemery 823df4242d Add support for per-backup agent encryption flag to hassio (#136828)
* Add support for per-backup agent encryption flag to hassio

* Improve comment

* Set password to None when supervisor should not encrypt
2025-01-29 18:23:25 +01:00
559 changed files with 18835 additions and 7621 deletions
+2
View File
@@ -46,6 +46,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to frontend pull request:
## Checklist
<!--
+1 -1
View File
@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.2"
HA_SHORT_VERSION: "2025.3"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.6
uses: github/codeql-action/init@v3.28.8
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.6
uses: github/codeql-action/analyze@v3.28.8
with:
category: "/language:python"
+1 -1
View File
@@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.1
hooks:
- id: codespell
args:
Generated
+4 -4
View File
@@ -625,8 +625,8 @@ build.json @home-assistant/supervisor
/tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
/tests/components/holiday/ @jrieger @gjohansson-ST
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98
/tests/components/home_connect/ @DavidMStraub @Diegorro98
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/homeassistant/components/homeassistant/ @home-assistant/core
/tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
@@ -765,8 +765,8 @@ build.json @home-assistant/supervisor
/tests/components/ituran/ @shmuelzon
/homeassistant/components/izone/ @Swamp-Ig
/tests/components/izone/ @Swamp-Ig
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
/tests/components/jellyfin/ @j-stienstra @ctalkington
/homeassistant/components/jellyfin/ @RunC0deRun @ctalkington
/tests/components/jellyfin/ @RunC0deRun @ctalkington
/homeassistant/components/jewish_calendar/ @tsvi
/tests/components/jewish_calendar/ @tsvi
/homeassistant/components/juicenet/ @jesserockz
Generated
+1 -1
View File
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.5.21
RUN pip3 install uv==0.5.27
WORKDIR /usr/src
+1
View File
@@ -146,6 +146,7 @@ def _extract_backup(
config_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*(keep)),
ignore_dangling_symlinks=True,
)
elif restore_content.restore_database:
for entry in KEEP_DATABASE:
+10
View File
@@ -161,6 +161,16 @@ FRONTEND_INTEGRATIONS = {
# integrations can be removed and database migration status is
# visible in frontend
"frontend",
# Hassio is an after dependency of backup, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here. Hassio needs to be setup before backup, otherwise
# the backup integration will think we are a container/core install
# when using HAOS or Supervised install.
"hassio",
# Backup is an after dependency of frontend, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here.
"backup",
}
RECORDER_INTEGRATIONS = {
# Setup after frontend
@@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],
"requirements": ["python-homeassistant-analytics==0.8.1"],
"requirements": ["python-homeassistant-analytics==0.9.0"],
"single_config_entry": true
}
@@ -272,6 +272,7 @@ class AnthropicConversationEntity(
continue
tool_input = llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.name,
tool_args=cast(dict[str, Any], tool_call.input),
)
@@ -134,7 +134,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id for said entry. When a new (zeroconf) service or device is
discovered, the identifier is first used to look up if it belongs to an
existing config entry. If that's the case, the unique_id from that entry is
re-used, otherwise the newly discovered identifier is used instead.
reused, otherwise the newly discovered identifier is used instead.
"""
assert self.atv
all_identifiers = set(self.atv.all_identifiers)
@@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address][0], data={}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import chat_session
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
Raises PipelineNotFound if no pipeline is found.
"""
pipeline_input = PipelineInput(
conversation_id=conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()
with chat_session.async_get_chat_session(hass, conversation_id) as session:
pipeline_input = PipelineInput(
conversation_id=session.conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()
@@ -33,7 +33,7 @@ from homeassistant.components.tts import (
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.helpers import chat_session, intent
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@@ -624,7 +624,7 @@ class PipelineRun:
return
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
def start(self, device_id: str | None) -> None:
def start(self, conversation_id: str, device_id: str | None) -> None:
"""Emit run start event."""
self._device_id = device_id
self._start_debug_recording_thread()
@@ -632,6 +632,7 @@ class PipelineRun:
data = {
"pipeline": self.pipeline.id,
"language": self.language,
"conversation_id": conversation_id,
}
if self.runner_data is not None:
data["runner_data"] = self.runner_data
@@ -1015,7 +1016,7 @@ class PipelineRun:
async def recognize_intent(
self,
intent_input: str,
conversation_id: str | None,
conversation_id: str,
device_id: str | None,
conversation_extra_system_prompt: str | None,
) -> str:
@@ -1063,11 +1064,11 @@ class PipelineRun:
agent_id=self.intent_agent,
extra_system_prompt=conversation_extra_system_prompt,
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
agent_id = user_input.agent_id
agent_id = self.intent_agent
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
intent_response: intent.IntentResponse | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
if not processed_locally:
# Sentence triggers override conversation agent
if (
trigger_response_text
@@ -1094,22 +1095,26 @@ class PipelineRun:
# It was already handled, create response and add to chat history
if intent_response is not None:
async with conversation.async_get_chat_session(
self.hass, user_input
) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(
self.hass, session, user_input
) as chat_log,
):
speech: str = intent_response.speech.get("plain", {}).get(
"speech", ""
)
chat_session.async_add_message(
conversation.Content(
role="assistant",
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=agent_id,
content=speech,
)
)
conversation_result = conversation.ConversationResult(
response=intent_response,
conversation_id=chat_session.conversation_id,
conversation_id=session.conversation_id,
)
else:
@@ -1122,6 +1127,7 @@ class PipelineRun:
context=user_input.context,
language=user_input.language,
agent_id=user_input.agent_id,
extra_system_prompt=user_input.extra_system_prompt,
)
speech = conversation_result.response.speech.get("plain", {}).get(
"speech", ""
@@ -1403,12 +1409,15 @@ def _pipeline_debug_recording_thread_proc(
wav_writer.close()
@dataclass
@dataclass(kw_only=True)
class PipelineInput:
"""Input to a pipeline run."""
run: PipelineRun
conversation_id: str
"""Identifier for the conversation."""
stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt."""
@@ -1424,9 +1433,6 @@ class PipelineInput:
tts_input: str | None = None
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
"""Identifier for the conversation."""
conversation_extra_system_prompt: str | None = None
"""Extra prompt information for the conversation agent."""
@@ -1435,7 +1441,7 @@ class PipelineInput:
async def execute(self) -> None:
"""Run pipeline."""
self.run.start(device_id=self.device_id)
self.run.start(conversation_id=self.conversation_id, device_id=self.device_id)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
@@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import (
chat_session,
config_validation as cv,
entity_registry as er,
)
from homeassistant.util import language as language_util
from .const import (
@@ -145,7 +149,6 @@ async def websocket_run(
# Arguments to PipelineInput
input_args: dict[str, Any] = {
"conversation_id": msg.get("conversation_id"),
"device_id": msg.get("device_id"),
}
@@ -233,38 +236,42 @@ async def websocket_run(
audio_settings=audio_settings or AudioSettings(),
)
pipeline_input = PipelineInput(**input_args)
with chat_session.async_get_chat_session(
hass, msg.get("conversation_id")
) as session:
input_args["conversation_id"] = session.conversation_id
pipeline_input = PipelineInput(**input_args)
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
# Confirm subscription
connection.send_result(msg["id"])
# Confirm subscription
connection.send_result(msg["id"])
run_task = hass.async_create_task(pipeline_input.execute())
run_task = hass.async_create_task(pipeline_input.execute())
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
)
)
)
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
@callback
@@ -63,6 +63,21 @@ 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("extra_system_prompt"): str,
}
),
cv.has_at_least_one_key("start_message", "start_media_id"),
),
"async_internal_start_conversation",
[AssistSatelliteEntityFeature.START_CONVERSATION],
)
hass.data[CONNECTION_TEST_DATA] = {}
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
@@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
ANNOUNCE = 1
"""Device supports remotely triggered announcements."""
START_CONVERSATION = 2
"""Device supports starting conversations."""
@@ -8,9 +8,9 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
import time
from typing import Any, Final, Literal, final
from typing import Any, Literal, final
from homeassistant.components import media_source, stt, tts
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
OPTION_PREFERRED,
AudioSettings,
@@ -27,14 +27,13 @@ from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, callback
from homeassistant.helpers import entity
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
from .const import AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
_LOGGER = logging.getLogger(__name__)
@@ -113,10 +112,10 @@ class AssistSatelliteEntity(entity.Entity):
_attr_vad_sensitivity_entity_id: str | None = None
_conversation_id: str | None = None
_conversation_id_time: float | None = None
_run_has_tts: bool = False
_is_announcing = False
_extra_system_prompt: str | None = None
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
_attr_tts_options: dict[str, Any] | None = None
_pipeline_task: asyncio.Task | None = None
@@ -216,6 +215,78 @@ class AssistSatelliteEntity(entity.Entity):
"""
raise NotImplementedError
async def async_internal_start_conversation(
self,
start_message: str | None = None,
start_media_id: str | None = None,
extra_system_prompt: str | None = None,
) -> None:
"""Start a conversation from the satellite.
If start_media_id is not provided, message is synthesized to
audio with the selected pipeline.
If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
Calls async_start_conversation.
"""
await self._cancel_running_pipeline()
# The Home Assistant built-in agent doesn't support conversations.
pipeline = async_get_pipeline(self.hass, self._resolve_pipeline())
if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT:
raise HomeAssistantError(
"Built-in conversation agent does not support starting conversations"
)
if start_message is None:
start_message = ""
announcement = await self._resolve_announcement_media_id(
start_message, start_media_id
)
if self._is_announcing:
raise SatelliteBusyError
self._is_announcing = True
# Provide our start info to the LLM so it understands context of incoming message
if extra_system_prompt is not None:
self._extra_system_prompt = extra_system_prompt
else:
self._extra_system_prompt = start_message or None
with (
# Not passing in a conversation ID will force a new one to be created
chat_session.async_get_chat_session(self.hass) as session,
conversation.async_get_chat_log(self.hass, session) as chat_log,
):
self._conversation_id = session.conversation_id
if start_message:
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=self.entity_id, content=start_message
)
)
try:
await self.async_start_conversation(announcement)
except Exception:
# Clear prompt on error
self._conversation_id = None
self._extra_system_prompt = None
raise
finally:
self._is_announcing = False
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
) -> None:
"""Start a conversation from the satellite."""
raise NotImplementedError
async def async_accept_pipeline_from_satellite(
self,
audio_stream: AsyncIterable[bytes],
@@ -226,6 +297,10 @@ class AssistSatelliteEntity(entity.Entity):
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
# Consume system prompt in first pipeline
extra_system_prompt = self._extra_system_prompt
self._extra_system_prompt = None
if self._wake_word_intercept_future and start_stage in (
PipelineStage.WAKE_WORD,
PipelineStage.STT,
@@ -266,50 +341,52 @@ class AssistSatelliteEntity(entity.Entity):
assert self._context is not None
# Reset conversation id if necessary
if self._conversation_id_time and (
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
):
self._conversation_id = None
self._conversation_id_time = None
# Set entity state based on pipeline events
self._run_has_tts = False
assert self.platform.config_entry is not None
self._pipeline_task = self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=self._conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
),
f"{self.entity_id}_pipeline",
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
with chat_session.async_get_chat_session(
self.hass, self._conversation_id
) as session:
# Store the conversation ID. If it is no longer valid, get_chat_session will reset it
self._conversation_id = session.conversation_id
self._pipeline_task = (
self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=session.conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
async def _cancel_running_pipeline(self) -> None:
"""Cancel the current pipeline if it's running."""
@@ -333,11 +410,6 @@ class AssistSatelliteEntity(entity.Entity):
self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.INTENT_START:
self._set_state(AssistSatelliteState.PROCESSING)
elif event.type is PipelineEventType.INTENT_END:
assert event.data is not None
# Update timeout
self._conversation_id_time = time.monotonic()
self._conversation_id = event.data["intent_output"]["conversation_id"]
elif event.type is PipelineEventType.TTS_START:
# Wait until tts_response_finished is called to return to waiting state
self._run_has_tts = True
@@ -7,6 +7,9 @@
"services": {
"announce": {
"service": "mdi:bullhorn"
},
"start_conversation": {
"service": "mdi:forum"
}
}
}
@@ -14,3 +14,23 @@ announce:
required: false
selector:
text:
start_conversation:
target:
entity:
domain: assist_satellite
supported_features:
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
fields:
start_message:
required: false
example: "You left the lights on in the living room. Turn them off?"
selector:
text:
start_media_id:
required: false
selector:
text:
extra_system_prompt:
required: false
selector:
text:
@@ -14,7 +14,7 @@
"services": {
"announce": {
"name": "Announce",
"description": "Let the satellite announce a message.",
"description": "Lets a satellite announce a message.",
"fields": {
"message": {
"name": "Message",
@@ -25,6 +25,24 @@
"description": "The media ID to announce instead of using text-to-speech."
}
}
},
"start_conversation": {
"name": "Start conversation",
"description": "Starts a conversation from a satellite.",
"fields": {
"start_message": {
"name": "Message",
"description": "The message to start with."
},
"start_media_id": {
"name": "Media ID",
"description": "The media ID to start with instead of using text-to-speech."
},
"extra_system_prompt": {
"name": "Extra system prompt",
"description": "Provide background information to the AI about the request."
}
}
}
}
}
@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
}
+13 -1
View File
@@ -26,14 +26,19 @@ from .manager import (
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
CreateBackupStage,
CreateBackupState,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
__all__ = [
@@ -43,10 +48,13 @@ __all__ = [
"BackupAgentError",
"BackupAgentPlatformProtocol",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"CreateBackupStage",
"CreateBackupState",
"Folder",
"IdleEvent",
"IncorrectPasswordError",
@@ -54,8 +62,12 @@ __all__ = [
"ManagerBackup",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+7 -8
View File
@@ -11,13 +11,7 @@ from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupError
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
from .models import AgentBackup, BackupAgentError
class BackupAgentUnreachableError(BackupAgentError):
@@ -94,11 +88,16 @@ class LocalBackupAgent(BackupAgent):
@abc.abstractmethod
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup.
"""Return the local path to an existing backup.
The method should return the path to the backup file with the specified id.
Raises BackupAgentError if the backup does not exist.
"""
@abc.abstractmethod
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
class BackupAgentPlatformProtocol(Protocol):
"""Define the format of backup platforms which implement backup agents."""
+28 -15
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup
from .util import read_backup
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename
async def async_get_backup_agents(
@@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
super().__init__()
self._hass = hass
self._backup_dir = Path(hass.config.path("backups"))
self._backups: dict[str, AgentBackup] = {}
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
self._loaded_backups = False
async def _load_backups(self) -> None:
@@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self._backups = backups
self._loaded_backups = True
def _read_backups(self) -> dict[str, AgentBackup]:
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
"""Read backups from disk."""
backups: dict[str, AgentBackup] = {}
backups: dict[str, tuple[AgentBackup, Path]] = {}
for backup_path in self._backup_dir.glob("*.tar"):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = backup
backups[backup.backup_id] = (backup, backup_path)
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
**kwargs: Any,
) -> None:
"""Upload a backup."""
self._backups[backup.backup_id] = backup
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
if not self._loaded_backups:
await self._load_backups()
return list(self._backups.values())
return [backup for backup, _ in self._backups.values()]
async def async_get_backup(
self,
@@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
if not (backup := self._backups.get(backup_id)):
if backup_id not in self._backups:
return None
backup_path = self.get_backup_path(backup_id)
backup, backup_path = self._backups[backup_id]
if not await self._hass.async_add_executor_job(backup_path.exists):
LOGGER.debug(
(
@@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
return backup
def get_backup_path(self, backup_id: str) -> Path:
"""Return the local path to a backup."""
return self._backup_dir / f"{backup_id}.tar"
"""Return the local path to an existing backup.
Raises BackupAgentError if the backup does not exist.
"""
try:
return self._backups[backup_id][1]
except KeyError as err:
raise BackupNotFound(f"Backup {backup_id} does not exist") from err
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
return self._backup_dir / suggested_filename(backup)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
if await self.async_get_backup(backup_id) is None:
return
if not self._loaded_backups:
await self._load_backups()
backup_path = self.get_backup_path(backup_id)
try:
backup_path = self.get_backup_path(backup_id)
except BackupNotFound:
return
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)
+13 -64
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import datetime as dt
from datetime import datetime, timedelta
@@ -252,7 +250,7 @@ class RetentionConfig:
"""Delete backups older than days."""
self._schedule_next(manager)
def _backups_filter(
def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return backups older than days to delete."""
@@ -269,7 +267,9 @@ class RetentionConfig:
< now
}
await _delete_filtered_backups(manager, _backups_filter)
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
manager.remove_next_delete_event = async_call_later(
manager.hass, timedelta(days=1), _delete_backups
@@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
password: str | None
async def _delete_filtered_backups(
manager: BackupManager,
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param manager: The backup manager.
:param backup_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await manager.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# only delete backups that are created with the saved automatic settings
backups = {
def _automatic_backups_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return automatic backups."""
return {
backup_id: backup
for backup_id, backup in backups.items()
if backup.with_automatic_settings
}
LOGGER.debug("Total automatic backups: %s", backups)
filtered_backups = backup_filter(backups)
if not filtered_backups:
return
# always delete oldest backup first
filtered_backups = dict(
sorted(
filtered_backups.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(filtered_backups) >= len(backups):
# Never delete the last backup.
last_backup = filtered_backups.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", filtered_backups)
if not filtered_backups:
return
backup_ids = list(filtered_backups)
delete_results = await asyncio.gather(
*(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
"""Delete backups exceeding the configured retention count."""
def _backups_filter(
def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
@@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
)
await _delete_filtered_backups(manager, _backups_filter)
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
+10 -6
View File
@@ -21,6 +21,7 @@ from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import BackupNotFound
@callback
@@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
try:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
async def _send_backup_no_password(
self,
+95 -24
View File
@@ -50,7 +50,14 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .store import BackupStore
from .util import (
AsyncIteratorReader,
@@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC):
"""Get restore events after core restart."""
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
@@ -685,6 +686,70 @@ class BackupManager:
return agent_errors
async def async_delete_filtered_backups(
self,
*,
include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param include_filter: A filter that should return the backups to consider for
deletion. Note: The newest of the backups returned by include_filter will
unconditionally be kept, even if delete_filter returns all backups.
:param delete_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await self.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# Run the include filter first to ensure we only consider backups that
# should be included in the deletion process.
backups = include_filter(backups)
LOGGER.debug("Total automatic backups: %s", backups)
backups_to_delete = delete_filter(backups)
if not backups_to_delete:
return
# always delete oldest backup first
backups_to_delete = dict(
sorted(
backups_to_delete.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(backups_to_delete) >= len(backups):
# Never delete the last backup.
last_backup = backups_to_delete.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", backups_to_delete)
if not backups_to_delete:
return
backup_ids = list(backups_to_delete)
delete_results = await asyncio.gather(
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def async_receive_backup(
self,
*,
@@ -898,7 +963,7 @@ class BackupManager:
)
backup_name = (
name
(name if name is None else name.strip())
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
)
extra_metadata = extra_metadata or {}
@@ -1166,7 +1231,11 @@ class BackupManager:
learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
translation_placeholders={
"failed_agents": ", ".join(
self.backup_agents[agent_id].name for agent_id in agent_errors
)
},
)
async def async_can_decrypt_on_download(
@@ -1346,10 +1415,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
if agent_config and not agent_config.protected:
password = None
backup = AgentBackup(
addons=[],
backup_id=backup_id,
database_included=include_database,
date=date_str,
extra_metadata=extra_metadata,
folders=[],
homeassistant_included=True,
homeassistant_version=HAVERSION,
name=backup_name,
protected=password is not None,
size=0,
)
local_agent_tar_file_path = None
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
on_progress(
CreateBackupEvent(
@@ -1391,19 +1474,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
# ValueError from json_bytes
raise BackupReaderWriterError(str(err)) from err
else:
backup = AgentBackup(
addons=[],
backup_id=backup_id,
database_included=include_database,
date=date_str,
extra_metadata=extra_metadata,
folders=[],
homeassistant_included=True,
homeassistant_version=HAVERSION,
name=backup_name,
protected=password is not None,
size=size_in_bytes,
)
backup = replace(backup, size=size_in_bytes)
async_add_executor_job = self._hass.async_add_executor_job
@@ -1517,7 +1588,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER]
if self._local_agent_id in agent_ids:
local_agent = manager.local_backup_agents[self._local_agent_id]
tar_file_path = local_agent.get_backup_path(backup.backup_id)
tar_file_path = local_agent.get_new_backup_path(backup)
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
else:
+18 -6
View File
@@ -41,12 +41,6 @@ class BaseBackup:
homeassistant_version: str | None # None if homeassistant_included is False
name: str
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
return {
key: val for key, val in asdict(self).items() if key != "extra_metadata"
}
@dataclass(frozen=True, kw_only=True)
class AgentBackup(BaseBackup):
@@ -83,7 +77,25 @@ class BackupError(HomeAssistantError):
error_code = "unknown"
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupManagerError(BackupError):
"""Backup manager error."""
error_code = "backup_manager_error"
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"
+5 -3
View File
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
STORAGE_VERSION_MINOR = 3
class StoredBackupData(TypedDict):
@@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]):
"""Migrate to the new version."""
data = old_data
if old_major_version == 1:
if old_minor_version < 2:
# Version 1.2 adds per agent settings, configurable backup time
if old_minor_version < 3:
# Version 1.2 bumped to 1.3 because 1.2 was changed several
# times during development.
# Version 1.3 adds per agent settings, configurable backup time
# and custom days
data["config"]["agents"] = {}
data["config"]["schedule"]["time"] = None
+58 -10
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from concurrent.futures import CancelledError, Future
import copy
from dataclasses import dataclass, replace
from io import BytesIO
@@ -12,6 +13,7 @@ import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
import threading
from typing import IO, Any, Self, cast
import aiohttp
@@ -20,8 +22,8 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from homeassistant.util.thread import ThreadWithException
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
@@ -117,6 +119,17 @@ def read_backup(backup_path: Path) -> AgentBackup:
)
def suggested_filename_from_name_date(name: str, date_str: str) -> str:
"""Suggest a filename for the backup."""
date = dt_util.parse_datetime(date_str, raise_on_error=True)
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
def suggested_filename(backup: AgentBackup) -> str:
"""Suggest a filename for the backup."""
return suggested_filename_from_name_date(backup.name, backup.date)
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
@@ -155,23 +168,38 @@ class AsyncIteratorReader:
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._stream = stream
self._buffer: bytes | None = None
self._next_future: Future[bytes | None] | None = None
self._pos: int = 0
async def _next(self) -> bytes | None:
"""Get the next chunk from the iterator."""
return await anext(self._stream, None)
def abort(self) -> None:
"""Abort the reader."""
self._aborted = True
if self._next_future is not None:
self._next_future.cancel()
def read(self, n: int = -1, /) -> bytes:
"""Read data from the iterator."""
result = bytearray()
while n < 0 or len(result) < n:
if not self._buffer:
self._buffer = asyncio.run_coroutine_threadsafe(
self._next_future = asyncio.run_coroutine_threadsafe(
self._next(), self._hass.loop
).result()
)
if self._aborted:
self._next_future.cancel()
raise AbortCipher
try:
self._buffer = self._next_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos = 0
if not self._buffer:
# The stream is exhausted
@@ -193,9 +221,11 @@ class AsyncIteratorWriter:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._pos: int = 0
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
self._write_future: Future[bytes | None] | None = None
def __aiter__(self) -> Self:
"""Return the iterator."""
@@ -207,13 +237,28 @@ class AsyncIteratorWriter:
return data
raise StopAsyncIteration
def abort(self) -> None:
"""Abort the writer."""
self._aborted = True
if self._write_future is not None:
self._write_future.cancel()
def tell(self) -> int:
"""Return the current position in the iterator."""
return self._pos
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
self._write_future = asyncio.run_coroutine_threadsafe(
self._queue.put(s), self._hass.loop
)
if self._aborted:
self._write_future.cancel()
raise AbortCipher
try:
self._write_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos += len(s)
return len(s)
@@ -403,7 +448,9 @@ def _encrypt_backup(
class _CipherWorkerStatus:
done: asyncio.Event
error: Exception | None = None
thread: ThreadWithException
reader: AsyncIteratorReader
thread: threading.Thread
writer: AsyncIteratorWriter
class _CipherBackupStreamer:
@@ -456,11 +503,13 @@ class _CipherBackupStreamer:
stream = await self._open_stream()
reader = AsyncIteratorReader(self._hass, stream)
writer = AsyncIteratorWriter(self._hass)
worker = ThreadWithException(
worker = threading.Thread(
target=self._cipher_func,
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
)
worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker)
worker_status = _CipherWorkerStatus(
done=asyncio.Event(), reader=reader, thread=worker, writer=writer
)
self._workers.append(worker_status)
worker.start()
return writer
@@ -468,9 +517,8 @@ class _CipherBackupStreamer:
async def wait(self) -> None:
"""Wait for the worker threads to finish."""
for worker in self._workers:
if not worker.thread.is_alive():
continue
worker.thread.raise_exc(AbortCipher)
worker.reader.abort()
worker.writer.abort()
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
+8 -4
View File
@@ -15,7 +15,7 @@ from .manager import (
IncorrectPasswordError,
ManagerStateEvent,
)
from .models import Folder
from .models import BackupNotFound, Folder
@callback
@@ -57,7 +57,7 @@ async def handle_info(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
"backups": [backup.as_frontend_json() for backup in backups.values()],
"backups": list(backups.values()),
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
"last_non_idle_event": manager.last_non_idle_event,
@@ -91,7 +91,7 @@ async def handle_details(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
"backup": backup.as_frontend_json() if backup else None,
"backup": backup,
},
)
@@ -151,6 +151,8 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else:
@@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
agent_id=msg["agent_id"],
password=msg.get("password"),
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported:
@@ -199,7 +203,7 @@ async def handle_can_decrypt_on_download(
vol.Optional("include_database", default=True): bool,
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
vol.Optional("name"): str,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("password"): vol.Any(str, None),
}
)
@@ -19,6 +19,8 @@ from .const import (
)
from .entity import BangOlufsenEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
@@ -28,7 +28,7 @@
"services": {
"join": {
"name": "Join",
"description": "Group player together.",
"description": "Groups players together under a single master speaker.",
"fields": {
"master": {
"name": "Master",
@@ -36,23 +36,23 @@
},
"entity_id": {
"name": "Entity",
"description": "Name of entity that will coordinate the grouping. Platform dependent."
"description": "Name of entity that will group to master speaker. Platform dependent."
}
}
},
"unjoin": {
"name": "Unjoin",
"description": "Unjoin the player from a group.",
"description": "Separates a player from a group.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Name of entity that will be unjoined from their group. Platform dependent."
"description": "Name of entity that will be separated from their group. Platform dependent."
}
}
},
"set_sleep_timer": {
"name": "Set sleep timer",
"description": "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0.",
"description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -62,7 +62,7 @@
},
"clear_sleep_timer": {
"name": "Clear sleep timer",
"description": "Clear a Bluesound timer.",
"description": "Clears a Bluesound timer.",
"fields": {
"entity_id": {
"name": "Entity",
+15 -2
View File
@@ -80,6 +80,7 @@ from .const import (
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@@ -297,7 +298,12 @@ async def async_discover_adapters(
async def async_update_device(
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
hass: HomeAssistant,
entry: ConfigEntry,
adapter: str,
details: AdapterDetails,
via_device_domain: str | None = None,
via_device_id: str | None = None,
) -> None:
"""Update device registry entry.
@@ -306,7 +312,8 @@ async def async_update_device(
update the device with the new location so they can
figure out where the adapter is.
"""
dr.async_get(hass).async_get_or_create(
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@@ -315,6 +322,10 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_VERSION),
)
if via_device_id:
device_registry.async_update_device(
device_entry.id, via_device_id=via_device_id
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
source_entry.title,
details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID),
)
return True
manager = _get_manager(hass)
+7 -1
View File
@@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner(
scanner, connection_slots, source_domain, source_model, source_config_entry_id
scanner,
connection_slots,
source_domain,
source_model,
source_config_entry_id,
source_device_id,
)
@@ -37,6 +37,7 @@ from .const import (
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
}
self._abort_if_unique_id_configured(updates=data)
manager = get_manager()
+1 -1
View File
@@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
SOURCE_LOCAL: Final = "local"
@@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots)
@@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner)
and source_domain
and source_config_entry_id
and not self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, scanner.source
)
):
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
@@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model,
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
CONF_SOURCE_DEVICE_ID: source_device_id,
},
)
)
@@ -19,8 +19,8 @@
"bleak-retry-connector==3.8.0",
"bluetooth-adapters==0.21.1",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.22.0",
"dbus-fast==2.30.2",
"habluetooth==3.14.0"
"bluetooth-data-tools==1.23.3",
"dbus-fast==2.32.0",
"habluetooth==3.21.0"
]
}
+9 -1
View File
@@ -39,6 +39,10 @@ def async_load_history_from_system(
now_monotonic = monotonic_time_coarse()
connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
adapter_to_source_address = {
adapter: details[ADAPTER_ADDRESS]
for adapter, details in adapters.adapters.items()
}
# Restore local adapters
for address, history in adapters.history.items():
@@ -50,7 +54,11 @@ def async_load_history_from_system(
BluetoothServiceInfoBleak.from_device_and_advertisement_data(
history.device,
history.advertisement_data,
history.source,
# history.source is really the adapter name
# for historical compatibility since BlueZ
# does not know the MAC address of the adapter
# so we need to convert it to the source address (MAC)
adapter_to_source_address.get(history.source, history.source),
now_monotonic,
True,
)
+32 -2
View File
@@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
config_entry: ConfigEntry
user_settings: BringUserSettingsResponse
lists: list[BringList]
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
"""Initialize the Bring data coordinator."""
@@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
update_interval=timedelta(seconds=90),
)
self.bring = bring
self.previous_lists: set[str] = set()
async def _async_update_data(self) -> dict[str, BringData]:
"""Fetch the latest data from bring."""
try:
lists_response = await self.bring.load_lists()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e:
@@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
) from exc
return self.data
if self.previous_lists - (
current_lists := {lst.listUuid for lst in self.lists}
):
self._purge_deleted_lists()
self.previous_lists = current_lists
list_dict: dict[str, BringData] = {}
for lst in lists_response.lists:
for lst in self.lists:
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
continue
try:
@@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
try:
await self.bring.login()
self.user_settings = await self.bring.get_all_user_settings()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail},
) from e
self._purge_deleted_lists()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}")
for lst in self.lists
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
+8 -6
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
from bring_api.types import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BringData, BringDataUpdateCoordinator
from .coordinator import BringDataUpdateCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
@@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringData,
bring_list: BringList,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, bring_list.lst.listUuid)
super().__init__(coordinator, bring_list.listUuid)
self._list_uuid = bring_list.lst.listUuid
self._list_uuid = bring_list.listUuid
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=bring_list.lst.name,
name=bring_list.name,
identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
},
manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
)
@@ -53,7 +53,7 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -65,7 +65,7 @@ rules:
status: exempt
comment: |
no repairs
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
+24 -11
View File
@@ -8,6 +8,7 @@ from enum import StrEnum
from bring_api import BringUserSettingsResponse
from bring_api.const import BRING_SUPPORTED_LOCALES
from bring_api.types import BringList
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -15,7 +16,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -90,16 +91,28 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities(
BringSensorEntity(
coordinator,
bring_list,
description,
)
for description in SENSOR_DESCRIPTIONS
for bring_list in coordinator.data.values()
)
@callback
def add_entities() -> None:
"""Add sensor entities."""
nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
async_add_entities(
BringSensorEntity(
coordinator,
bring_list,
description,
)
for description in SENSOR_DESCRIPTIONS
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
class BringSensorEntity(BringBaseEntity, SensorEntity):
@@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringData,
bring_list: BringList,
entity_description: BringSensorEntityDescription,
) -> None:
"""Initialize the entity."""
+19 -9
View File
@@ -12,6 +12,7 @@ from bring_api import (
BringNotificationType,
BringRequestException,
)
from bring_api.types import BringList
import voluptuous as vol
from homeassistant.components.todo import (
@@ -20,7 +21,7 @@ from homeassistant.components.todo import (
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -45,14 +46,23 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities(
BringTodoListEntity(
coordinator,
bring_list=bring_list,
)
for bring_list in coordinator.data.values()
)
@callback
def add_entities() -> None:
"""Add or remove todo list entities."""
nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
async_add_entities(
BringTodoListEntity(coordinator, bring_list)
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
platform = entity_platform.async_get_current_platform()
@@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
)
def __init__(
self, coordinator: BringDataUpdateCoordinator, bring_list: BringData
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, bring_list)
@@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry()
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.9.1"]
"requirements": ["bthome-ble==3.12.3"]
}
+31 -10
View File
@@ -67,6 +67,21 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
# Channel (-)
(BTHomeExtendedSensorDeviceClass.CHANNEL, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.CHANNEL),
state_class=SensorStateClass.MEASUREMENT,
),
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,
Units.CONDUCTIVITY,
): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
device_class=SensorDeviceClass.CONDUCTIVITY,
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
state_class=SensorStateClass.MEASUREMENT,
),
# Count (-)
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
key=str(BTHomeSensorDeviceClass.COUNT),
@@ -99,6 +114,12 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
# Directions (°)
(BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
),
# Distance (mm)
(
BTHomeSensorDeviceClass.DISTANCE,
@@ -221,6 +242,16 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
# Precipitation (mm)
(
BTHomeExtendedSensorDeviceClass.PRECIPITATION,
Units.LENGTH_MILLIMETERS,
): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}",
device_class=SensorDeviceClass.PRECIPITATION,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
# Pressure (mbar)
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
@@ -357,16 +388,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.TOTAL,
),
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,
Units.CONDUCTIVITY,
): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
device_class=SensorDeviceClass.CONDUCTIVITY,
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
state_class=SensorStateClass.MEASUREMENT,
),
}
+12 -7
View File
@@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service(
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
)
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
image = (
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
if camera.use_stream_for_stills
else await camera.async_camera_image()
)
try:
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
image = (
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
if camera.use_stream_for_stills
else await camera.async_camera_image()
)
except TimeoutError as err:
raise HomeAssistantError(
f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds"
) from err
if image is None:
return
@@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
try:
await hass.async_add_executor_job(_write_image, snapshot_file, image)
except OSError as err:
_LOGGER.error("Can't write image to file: %s", err)
raise HomeAssistantError(f"Can't write image to file: {err}") from err
async def async_handle_play_stream_service(
@@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView)
hass.http.register_view(DownloadSupportPackageView)
_CLOUD_ERRORS.update(
{
@@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
return self.json_message("ok")
class DownloadSupportPackageView(HomeAssistantView):
"""Download support package view."""
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
def _generate_markdown(
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
markdown = ""
first = True
for key, value in domain_info.items():
markdown += f"{key} | {value}\n"
if first:
markdown += "--- | ---\n"
first = False
return markdown + "\n"
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
f"<details><summary>{domain}</summary>\n\n"
f"{domain_info_md}"
"</details>\n\n"
)
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""
hass = request.app[KEY_HASS]
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
markdown = self._generate_markdown(hass_info, domain_health)
return web.Response(
body=markdown,
content_type="text/markdown",
headers={
"Content-Disposition": 'attachment; filename="support_package.md"'
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
@websocket_api.async_response
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.88.1"],
"requirements": ["hass-nabucasa==0.89.0"],
"single_config_entry": true
}
+160 -8
View File
@@ -38,6 +38,156 @@ ATTR_GENDER = "gender"
DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"}
SUPPORT_LANGUAGES = list(TTS_VOICES)
DEFAULT_VOICES = {
"af-ZA": "AdriNeural",
"am-ET": "MekdesNeural",
"ar-AE": "FatimaNeural",
"ar-BH": "LailaNeural",
"ar-DZ": "AminaNeural",
"ar-EG": "SalmaNeural",
"ar-IQ": "RanaNeural",
"ar-JO": "SanaNeural",
"ar-KW": "NouraNeural",
"ar-LB": "LaylaNeural",
"ar-LY": "ImanNeural",
"ar-MA": "MounaNeural",
"ar-OM": "AbdullahNeural",
"ar-QA": "AmalNeural",
"ar-SA": "ZariyahNeural",
"ar-SY": "AmanyNeural",
"ar-TN": "ReemNeural",
"ar-YE": "MaryamNeural",
"az-AZ": "BabekNeural",
"bg-BG": "KalinaNeural",
"bn-BD": "NabanitaNeural",
"bn-IN": "TanishaaNeural",
"bs-BA": "GoranNeural",
"ca-ES": "JoanaNeural",
"cs-CZ": "VlastaNeural",
"cy-GB": "NiaNeural",
"da-DK": "ChristelNeural",
"de-AT": "IngridNeural",
"de-CH": "LeniNeural",
"de-DE": "KatjaNeural",
"el-GR": "AthinaNeural",
"en-AU": "NatashaNeural",
"en-CA": "ClaraNeural",
"en-GB": "LibbyNeural",
"en-HK": "YanNeural",
"en-IE": "EmilyNeural",
"en-IN": "NeerjaNeural",
"en-KE": "AsiliaNeural",
"en-NG": "EzinneNeural",
"en-NZ": "MollyNeural",
"en-PH": "RosaNeural",
"en-SG": "LunaNeural",
"en-TZ": "ImaniNeural",
"en-US": "JennyNeural",
"en-ZA": "LeahNeural",
"es-AR": "ElenaNeural",
"es-BO": "SofiaNeural",
"es-CL": "CatalinaNeural",
"es-CO": "SalomeNeural",
"es-CR": "MariaNeural",
"es-CU": "BelkysNeural",
"es-DO": "RamonaNeural",
"es-EC": "AndreaNeural",
"es-ES": "ElviraNeural",
"es-GQ": "TeresaNeural",
"es-GT": "MartaNeural",
"es-HN": "KarlaNeural",
"es-MX": "DaliaNeural",
"es-NI": "YolandaNeural",
"es-PA": "MargaritaNeural",
"es-PE": "CamilaNeural",
"es-PR": "KarinaNeural",
"es-PY": "TaniaNeural",
"es-SV": "LorenaNeural",
"es-US": "PalomaNeural",
"es-UY": "ValentinaNeural",
"es-VE": "PaolaNeural",
"et-EE": "AnuNeural",
"eu-ES": "AinhoaNeural",
"fa-IR": "DilaraNeural",
"fi-FI": "SelmaNeural",
"fil-PH": "BlessicaNeural",
"fr-BE": "CharlineNeural",
"fr-CA": "SylvieNeural",
"fr-CH": "ArianeNeural",
"fr-FR": "DeniseNeural",
"ga-IE": "OrlaNeural",
"gl-ES": "SabelaNeural",
"gu-IN": "DhwaniNeural",
"he-IL": "HilaNeural",
"hi-IN": "SwaraNeural",
"hr-HR": "GabrijelaNeural",
"hu-HU": "NoemiNeural",
"hy-AM": "AnahitNeural",
"id-ID": "GadisNeural",
"is-IS": "GudrunNeural",
"it-IT": "ElsaNeural",
"ja-JP": "NanamiNeural",
"jv-ID": "SitiNeural",
"ka-GE": "EkaNeural",
"kk-KZ": "AigulNeural",
"km-KH": "SreymomNeural",
"kn-IN": "SapnaNeural",
"ko-KR": "SunHiNeural",
"lo-LA": "KeomanyNeural",
"lt-LT": "OnaNeural",
"lv-LV": "EveritaNeural",
"mk-MK": "MarijaNeural",
"ml-IN": "SobhanaNeural",
"mn-MN": "BataaNeural",
"mr-IN": "AarohiNeural",
"ms-MY": "YasminNeural",
"mt-MT": "GraceNeural",
"my-MM": "NilarNeural",
"nb-NO": "IselinNeural",
"ne-NP": "HemkalaNeural",
"nl-BE": "DenaNeural",
"nl-NL": "ColetteNeural",
"pl-PL": "AgnieszkaNeural",
"ps-AF": "LatifaNeural",
"pt-BR": "FranciscaNeural",
"pt-PT": "RaquelNeural",
"ro-RO": "AlinaNeural",
"ru-RU": "SvetlanaNeural",
"si-LK": "ThiliniNeural",
"sk-SK": "ViktoriaNeural",
"sl-SI": "PetraNeural",
"so-SO": "UbaxNeural",
"sq-AL": "AnilaNeural",
"sr-RS": "SophieNeural",
"su-ID": "TutiNeural",
"sv-SE": "SofieNeural",
"sw-KE": "ZuriNeural",
"sw-TZ": "RehemaNeural",
"ta-IN": "PallaviNeural",
"ta-LK": "SaranyaNeural",
"ta-MY": "KaniNeural",
"ta-SG": "VenbaNeural",
"te-IN": "ShrutiNeural",
"th-TH": "AcharaNeural",
"tr-TR": "EmelNeural",
"uk-UA": "PolinaNeural",
"ur-IN": "GulNeural",
"ur-PK": "UzmaNeural",
"uz-UZ": "MadinaNeural",
"vi-VN": "HoaiMyNeural",
"wuu-CN": "XiaotongNeural",
"yue-CN": "XiaoMinNeural",
"zh-CN": "XiaoxiaoNeural",
"zh-CN-henan": "YundengNeural",
"zh-CN-liaoning": "XiaobeiNeural",
"zh-CN-shaanxi": "XiaoniNeural",
"zh-CN-shandong": "YunxiangNeural",
"zh-CN-sichuan": "YunxiNeural",
"zh-HK": "HiuMaanNeural",
"zh-TW": "HsiaoChenNeural",
"zu-ZA": "ThandoNeural",
}
_LOGGER = logging.getLogger(__name__)
@@ -186,12 +336,13 @@ class CloudTTSEntity(TextToSpeechEntity):
"""Load TTS from Home Assistant Cloud."""
gender: Gender | str | None = options.get(ATTR_GENDER)
gender = handle_deprecated_gender(self.hass, gender)
original_voice: str | None = options.get(ATTR_VOICE)
if original_voice is None and language == self._language:
original_voice = self._voice
original_voice: str = options.get(
ATTR_VOICE,
self._voice if language == self._language else DEFAULT_VOICES[language],
)
voice = handle_deprecated_voice(self.hass, original_voice)
if voice not in TTS_VOICES[language]:
default_voice = TTS_VOICES[language][0]
default_voice = DEFAULT_VOICES[language]
_LOGGER.debug(
"Unsupported voice %s detected, falling back to default %s for %s",
voice,
@@ -266,12 +417,13 @@ class CloudProvider(Provider):
assert self.hass is not None
gender: Gender | str | None = options.get(ATTR_GENDER)
gender = handle_deprecated_gender(self.hass, gender)
original_voice: str | None = options.get(ATTR_VOICE)
if original_voice is None and language == self._language:
original_voice = self._voice
original_voice: str = options.get(
ATTR_VOICE,
self._voice if language == self._language else DEFAULT_VOICES[language],
)
voice = handle_deprecated_voice(self.hass, original_voice)
if voice not in TTS_VOICES[language]:
default_voice = TTS_VOICES[language][0]
default_voice = DEFAULT_VOICES[language]
_LOGGER.debug(
"Unsupported voice %s detected, falling back to default %s for %s",
voice,
@@ -302,7 +302,8 @@ def config_entries_progress(
[
flw
for flw in hass.config_entries.flow.async_progress()
if flw["context"]["source"] != config_entries.SOURCE_USER
if flw["context"]["source"]
not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER)
],
)
@@ -30,6 +30,16 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .chat_log import (
AssistantContent,
ChatLog,
Content,
ConverseError,
SystemContent,
ToolResultContent,
UserContent,
async_get_chat_log,
)
from .const import (
ATTR_AGENT_ID,
ATTR_CONVERSATION_ID,
@@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
from .session import (
ChatSession,
Content,
ConverseError,
NativeContent,
async_get_chat_session,
)
from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [
"DOMAIN",
"HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT",
"ChatSession",
"AssistantContent",
"ChatLog",
"Content",
"ConversationEntity",
"ConversationEntityFeature",
@@ -69,11 +73,13 @@ __all__ = [
"ConversationResult",
"ConversationTraceEventType",
"ConverseError",
"NativeContent",
"SystemContent",
"ToolResultContent",
"UserContent",
"async_conversation_trace_append",
"async_converse",
"async_get_agent_info",
"async_get_chat_session",
"async_get_chat_log",
"async_set_agent",
"async_setup",
"async_unset_agent",
@@ -0,0 +1,299 @@
"""Conversation history."""
from __future__ import annotations
from collections.abc import AsyncGenerator, Generator
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
import logging
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import chat_session, intent, llm, template
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import DOMAIN
from .models import ConversationInput, ConversationResult
DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log")
LOGGER = logging.getLogger(__name__)
@contextmanager
def async_get_chat_log(
hass: HomeAssistant,
session: chat_session.ChatSession,
user_input: ConversationInput | None = None,
) -> Generator[ChatLog]:
"""Return chat log for a specific chat session."""
all_history = hass.data.get(DATA_CHAT_HISTORY)
if all_history is None:
all_history = {}
hass.data[DATA_CHAT_HISTORY] = all_history
history = all_history.get(session.conversation_id)
if history:
history = replace(history, content=history.content.copy())
else:
history = ChatLog(hass, session.conversation_id)
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_history.pop(session.conversation_id)
session.async_on_cleanup(do_cleanup)
if user_input is not None:
history.async_add_user_content(UserContent(content=user_input.text))
last_message = history.content[-1]
yield history
if history.content[-1] is last_message:
LOGGER.debug(
"History opened but no assistant message was added, ignoring update"
)
return
all_history[session.conversation_id] = history
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
Will not be stored in the history.
"""
def __init__(
self, message: str, conversation_id: str, response: intent.IntentResponse
) -> None:
"""Initialize the error."""
super().__init__(message)
self.conversation_id = conversation_id
self.response = response
def as_conversation_result(self) -> ConversationResult:
"""Return the error as a conversation result."""
return ConversationResult(
response=self.response,
conversation_id=self.conversation_id,
)
@dataclass(frozen=True)
class SystemContent:
"""Base class for chat messages."""
role: str = field(init=False, default="system")
content: str
@dataclass(frozen=True)
class UserContent:
"""Assistant content."""
role: str = field(init=False, default="user")
content: str
@dataclass(frozen=True)
class AssistantContent:
"""Assistant content."""
role: str = field(init=False, default="assistant")
agent_id: str
content: str
tool_calls: list[llm.ToolInput] | None = None
@dataclass(frozen=True)
class ToolResultContent:
"""Tool result content."""
role: str = field(init=False, default="tool_result")
agent_id: str
tool_call_id: str
tool_name: str
tool_result: JsonObjectType
Content = SystemContent | UserContent | AssistantContent | ToolResultContent
@dataclass
class ChatLog:
"""Class holding the chat history of a specific conversation."""
hass: HomeAssistant
conversation_id: str
content: list[Content] = field(default_factory=lambda: [SystemContent(content="")])
extra_system_prompt: str | None = None
llm_api: llm.APIInstance | None = None
@callback
def async_add_user_content(self, content: UserContent) -> None:
"""Add user content to the log."""
self.content.append(content)
@callback
def async_add_assistant_content_without_tools(
self, content: AssistantContent
) -> None:
"""Add assistant content to the log."""
if content.tool_calls is not None:
raise ValueError("Tool calls not allowed")
self.content.append(content)
async def async_add_assistant_content(
self, content: AssistantContent
) -> AsyncGenerator[ToolResultContent]:
"""Add assistant content."""
self.content.append(content)
if content.tool_calls is None:
return
if self.llm_api is None:
raise ValueError("No LLM API configured")
for tool_input in content.tool_calls:
LOGGER.debug(
"Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
)
try:
tool_result = await self.llm_api.async_call_tool(tool_input)
except (HomeAssistantError, vol.Invalid) as e:
tool_result = {"error": type(e).__name__}
if str(e):
tool_result["error_text"] = str(e)
LOGGER.debug("Tool response: %s", tool_result)
response_content = ToolResultContent(
agent_id=content.agent_id,
tool_call_id=tool_input.id,
tool_name=tool_input.tool_name,
tool_result=tool_result,
)
self.content.append(response_content)
yield response_content
async def async_update_llm_data(
self,
conversing_domain: str,
user_input: ConversationInput,
user_llm_hass_api: str | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_context = llm.LLMContext(
platform=conversing_domain,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=DOMAIN,
device_id=user_input.device_id,
)
llm_api: llm.APIInstance | None = None
if user_llm_hass_api:
try:
llm_api = await llm.async_get_api(
self.hass,
user_llm_hass_api,
llm_context,
)
except HomeAssistantError as err:
LOGGER.error(
"Error getting LLM API %s for %s: %s",
user_llm_hass_api,
conversing_domain,
err,
)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API",
)
raise ConverseError(
f"Error getting LLM API {user_llm_hass_api}",
conversation_id=self.conversation_id,
response=intent_response,
) from err
user_name: str | None = None
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
)
):
user_name = user.name
try:
prompt_parts = [
template.Template(
llm.BASE_PROMPT
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
if llm_api:
prompt_parts.append(llm_api.api_prompt)
extra_system_prompt = (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt
)
if extra_system_prompt:
prompt_parts.append(extra_system_prompt)
prompt = "\n".join(prompt_parts)
self.llm_api = llm_api
self.extra_system_prompt = extra_system_prompt
self.content[0] = SystemContent(content=prompt)
LOGGER.debug("Prompt: %s", self.content)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
trace.async_conversation_trace_append(
trace.ConversationTraceEventType.AGENT_DETAIL,
{
"messages": self.content,
"tools": self.llm_api.tools if self.llm_api else None,
},
)
@@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.helpers import (
area_registry as ar,
chat_session,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@@ -54,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
@@ -62,7 +64,6 @@ from .const import (
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .session import Content, async_get_chat_session
from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
@@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
response: intent.IntentResponse | None = None
async with async_get_chat_session(self.hass, user_input) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
async_get_chat_log(self.hass, session, user_input) as chat_log,
):
# Check if a trigger matched
if trigger_result := await self.async_recognize_sentence_trigger(
user_input
@@ -373,16 +379,15 @@ class DefaultAgent(ConversationEntity):
)
speech: str = response.speech.get("plain", {}).get("speech", "")
chat_session.async_add_message(
Content(
role="assistant",
agent_id=user_input.agent_id,
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id=user_input.agent_id, # type: ignore[arg-type]
content=speech,
)
)
return ConversationResult(
response=response, conversation_id=chat_session.conversation_id
response=response, conversation_id=session.conversation_id
)
async def _async_process_intent_result(
@@ -805,6 +810,37 @@ class DefaultAgent(ConversationEntity):
elif intent_response.unmatched_states:
state1 = intent_response.unmatched_states[0]
state1_translated = ""
if state1 is not None:
device_class = state1.attributes.get("device_class", "_")
state_translations = await translation.async_get_translations(
self.hass, language, "state", {state1.domain}
)
entity_component_translations = await translation.async_get_translations(
self.hass, language, "entity_component", {state1.domain}
)
state_key = f"component.{state1.domain}.state.{device_class}.{state1.state}"
entity_component_key = f"component.{state1.domain}.entity_component.{device_class}.state.{state1.state}"
state_translation = state_translations.get(state_key)
entity_component_translation = entity_component_translations.get(
entity_component_key, state_translation
)
if (not state_translation) and entity_component_translation:
# Missing "state._" translation
state1_translated = entity_component_translation
elif state_translation and (not entity_component_translation):
# Missing "entity_component._.state" translation
state1_translated = state_translation
if state_translation and (
state_translation != entity_component_translation
):
# Prefer "state._" translation since English is the fallback
# language and doesn't necessarily have it.
state1_translated = state_translation
# Render response template
speech_slots = {
entity_name: entity_value.text or entity_value.value
@@ -822,6 +858,7 @@ class DefaultAgent(ConversationEntity):
if state1 is not None
else None
),
"state_translated": state1_translated,
"query": {
# Entity states that matched the query (e.g, "on")
"matched": [
@@ -1,359 +0,0 @@
"""Conversation history."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
import logging
from typing import Literal
import voluptuous as vol
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HassJob,
HassJobType,
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import intent, llm, template
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util, ulid as ulid_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import DOMAIN
from .models import ConversationInput, ConversationResult
DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey(
"conversation_chat_session"
)
DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey(
"conversation_chat_session_cleanup"
)
LOGGER = logging.getLogger(__name__)
CONVERSATION_TIMEOUT = timedelta(minutes=5)
class SessionCleanup:
"""Helper to clean up the history."""
unsub: CALLBACK_TYPE | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the history cleanup."""
self.hass = hass
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
self.cleanup_job = HassJob(
self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback
)
@callback
def schedule(self) -> None:
"""Schedule the cleanup."""
if self.unsub:
return
self.unsub = async_call_later(
self.hass,
CONVERSATION_TIMEOUT.total_seconds() + 1,
self.cleanup_job,
)
@callback
def _on_hass_stop(self, event: Event) -> None:
"""Cancel the cleanup on shutdown."""
if self.unsub:
self.unsub()
self.unsub = None
@callback
def _cleanup(self, now: datetime) -> None:
"""Clean up the history and schedule follow-up if necessary."""
self.unsub = None
all_history = self.hass.data[DATA_CHAT_HISTORY]
# We mutate original object because current commands could be
# yielding history based on it.
for conversation_id, history in list(all_history.items()):
if history.last_updated + CONVERSATION_TIMEOUT < now:
del all_history[conversation_id]
# Still conversations left, check again in timeout time.
if all_history:
self.schedule()
@asynccontextmanager
async def async_get_chat_session(
hass: HomeAssistant,
user_input: ConversationInput,
) -> AsyncGenerator[ChatSession]:
"""Return chat session."""
all_history = hass.data.get(DATA_CHAT_HISTORY)
if all_history is None:
all_history = {}
hass.data[DATA_CHAT_HISTORY] = all_history
hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass)
history: ChatSession | None = None
if user_input.conversation_id is None:
conversation_id = ulid_util.ulid_now()
elif history := all_history.get(user_input.conversation_id):
conversation_id = user_input.conversation_id
else:
# Conversation IDs are ULIDs. We generate a new one if not provided.
# If an old OLID is passed in, we will generate a new one to indicate
# a new conversation was started. If the user picks their own, they
# want to track a conversation and we respect it.
try:
ulid_util.ulid_to_bytes(user_input.conversation_id)
conversation_id = ulid_util.ulid_now()
except ValueError:
conversation_id = user_input.conversation_id
if history:
history = replace(history, messages=history.messages.copy())
else:
history = ChatSession(hass, conversation_id, user_input.agent_id)
message: Content = Content(
role="user",
agent_id=user_input.agent_id,
content=user_input.text,
)
history.async_add_message(message)
yield history
if history.messages[-1] is message:
LOGGER.debug(
"History opened but no assistant message was added, ignoring update"
)
return
history.last_updated = dt_util.utcnow()
all_history[conversation_id] = history
hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule()
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
Will not be stored in the history.
"""
def __init__(
self, message: str, conversation_id: str, response: intent.IntentResponse
) -> None:
"""Initialize the error."""
super().__init__(message)
self.conversation_id = conversation_id
self.response = response
def as_conversation_result(self) -> ConversationResult:
"""Return the error as a conversation result."""
return ConversationResult(
response=self.response,
conversation_id=self.conversation_id,
)
@dataclass
class Content:
"""Base class for chat messages."""
role: Literal["system", "assistant", "user"]
agent_id: str | None
content: str
@dataclass(frozen=True)
class NativeContent[_NativeT]:
"""Native content."""
role: str = field(init=False, default="native")
agent_id: str
content: _NativeT
@dataclass
class ChatSession[_NativeT]:
"""Class holding all information for a specific conversation."""
hass: HomeAssistant
conversation_id: str
agent_id: str | None
user_name: str | None = None
messages: list[Content | NativeContent[_NativeT]] = field(
default_factory=lambda: [Content(role="system", agent_id=None, content="")]
)
extra_system_prompt: str | None = None
llm_api: llm.APIInstance | None = None
last_updated: datetime = field(default_factory=dt_util.utcnow)
@callback
def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None:
"""Process intent."""
if message.role == "system":
raise ValueError("Cannot add system messages to history")
if message.role != "native" and self.messages[-1].role == message.role:
raise ValueError("Cannot add two assistant or user messages in a row")
self.messages.append(message)
@callback
def async_get_messages(
self, agent_id: str | None = None
) -> list[Content | NativeContent[_NativeT]]:
"""Get messages for a specific agent ID.
This will filter out any native message tied to other agent IDs.
It can still include assistant/user messages generated by other agents.
"""
return [
message
for message in self.messages
if message.role != "native" or message.agent_id == agent_id
]
async def async_update_llm_data(
self,
conversing_domain: str,
user_input: ConversationInput,
user_llm_hass_api: str | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_context = llm.LLMContext(
platform=conversing_domain,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=DOMAIN,
device_id=user_input.device_id,
)
llm_api: llm.APIInstance | None = None
if user_llm_hass_api:
try:
llm_api = await llm.async_get_api(
self.hass,
user_llm_hass_api,
llm_context,
)
except HomeAssistantError as err:
LOGGER.error(
"Error getting LLM API %s for %s: %s",
user_llm_hass_api,
conversing_domain,
err,
)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API",
)
raise ConverseError(
f"Error getting LLM API {user_llm_hass_api}",
conversation_id=self.conversation_id,
response=intent_response,
) from err
user_name: str | None = None
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
)
):
user_name = user.name
try:
prompt_parts = [
template.Template(
llm.BASE_PROMPT
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
if llm_api:
prompt_parts.append(llm_api.api_prompt)
extra_system_prompt = (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt
)
if extra_system_prompt:
prompt_parts.append(extra_system_prompt)
prompt = "\n".join(prompt_parts)
self.llm_api = llm_api
self.user_name = user_name
self.extra_system_prompt = extra_system_prompt
self.messages[0] = Content(
role="system",
agent_id=user_input.agent_id,
content=prompt,
)
LOGGER.debug("Prompt: %s", self.messages)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
trace.async_conversation_trace_append(
trace.ConversationTraceEventType.AGENT_DETAIL,
{
"messages": self.messages,
"tools": self.llm_api.tools if self.llm_api else None,
},
)
async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType:
"""Invoke LLM tool for the configured LLM API."""
if not self.llm_api:
raise ValueError("No LLM API configured")
LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args)
try:
tool_response = await self.llm_api.async_call_tool(tool_input)
except (HomeAssistantError, vol.Invalid) as e:
tool_response = {"error": type(e).__name__}
if str(e):
tool_response["error_text"] = str(e)
LOGGER.debug("Tool response: %s", tool_response)
return tool_response
+1 -1
View File
@@ -14,7 +14,7 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.0.2",
"aiodhcpwatcher==1.0.3",
"aiodiscover==2.1.0",
"cached-ipaddress==0.8.0"
]
@@ -3,7 +3,7 @@
Data is fetched from DWD:
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor,extremem
Unwetterwarnungen (Stufe 3)
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1)
@@ -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.10", "deebot-client==11.1.0b1"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"]
}
@@ -2,6 +2,7 @@
from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
@@ -39,17 +40,23 @@ async def async_setup_entry(
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None:
"""Set up the light entities for a device."""
device = coordinator.hub.devices[device_address]
def async_setup_device_entities(
device_address: str | dict[str, EheimDigitalDevice],
) -> None:
"""Set up the climate entities for one or multiple devices."""
entities: list[EheimDigitalHeaterClimate] = []
if isinstance(device_address, str):
device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalHeater):
entities.append(EheimDigitalHeaterClimate(coordinator, device))
coordinator.known_devices.add(device.mac_address)
if isinstance(device, EheimDigitalHeater):
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
@@ -69,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_mode = PRESET_NONE
_attr_translation_key = "heater"
_attr_name = None
def __init__(
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
@@ -2,8 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any
from collections.abc import Callable
from aiohttp import ClientError
from eheimdigital.device import EheimDigitalDevice
@@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
type AsyncSetupDeviceEntitiesCallback = Callable[
[str | dict[str, EheimDigitalDevice]], None
]
class EheimDigitalUpdateCoordinator(
@@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator(
if device_address not in self.known_devices:
for platform_callback in self.platform_callbacks:
await platform_callback(device_address)
platform_callback(device_address)
async def _async_receive_callback(self) -> None:
self.async_set_updated_data(self.hub.devices)
+18 -13
View File
@@ -3,6 +3,7 @@
from typing import Any
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import EheimDigitalClientError, LightMode
from homeassistant.components.light import (
@@ -37,24 +38,28 @@ async def async_setup_entry(
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None:
"""Set up the light entities for a device."""
device = coordinator.hub.devices[device_address]
def async_setup_device_entities(
device_address: str | dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalClassicLEDControlLight] = []
if isinstance(device_address, str):
device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalClassicLEDControl):
for channel in range(2):
if len(device.tankconfig[channel]) > 0:
entities.append(
EheimDigitalClassicLEDControlLight(
coordinator, device, channel
)
)
coordinator.known_devices.add(device.mac_address)
if isinstance(device, EheimDigitalClassicLEDControl):
for channel in range(2):
if len(device.tankconfig[channel]) > 0:
entities.append(
EheimDigitalClassicLEDControlLight(coordinator, device, channel)
)
coordinator.known_devices.add(device.mac_address)
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalClassicLEDControlLight(
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"title": "Searching for Energenie-Power-Sockets Devices.",
"title": "Searching for Energenie Power Sockets devices",
"description": "Choose a discovered device.",
"data": {
"device": "[%key:common::config_flow::data::device%]"
@@ -13,7 +13,7 @@
"abort": {
"usb_error": "Couldn't access USB devices!",
"no_device": "Unable to discover any (new) supported device.",
"device_not_found": "No device was found for the given id.",
"device_not_found": "No device was found for the given ID.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
@@ -2,13 +2,22 @@
from __future__ import annotations
from pyenphase import EnvoyData
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from httpx import HTTPError
from pyenphase import EnvoyData
from pyenphase.exceptions import EnvoyError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnphaseUpdateCoordinator
ACTIONERRORS = (EnvoyError, HTTPError)
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
"""Defines a base envoy entity."""
@@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
data = self.coordinator.envoy.data
assert data is not None
return data
def exception_handler[_EntityT: EnvoyBaseEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Enphase Envoy calls to handle exceptions.
A decorator that wraps the passed in function, catches enphase_envoy errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except ACTIONERRORS as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_error",
translation_placeholders={
"host": self.coordinator.envoy.host,
"args": error.args[0],
"action": func.__name__,
"entity": self.entity_id,
},
) from error
return handler
@@ -400,6 +400,9 @@
},
"envoy_error": {
"message": "Error communicating with Envoy API on {host}: {args}"
},
"action_error": {
"message": "Failed to execute {action} for {entity}, host: {host}: {args}"
}
}
}
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert enpower is not None
return self.entity_description.value_fn(enpower)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the Enpower switch."""
await self.entity_description.turn_on_fn(self.envoy)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the Enpower switch."""
await self.entity_description.turn_off_fn(self.envoy)
@@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert relay is not None
return self.entity_description.value_fn(relay)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on (close) the dry contact."""
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
self.async_write_ha_state()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off (open) the dry contact."""
if await self.entity_description.turn_off_fn(self.envoy, self.relay_id):
@@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the storage settings switch."""
await self.entity_description.turn_on_fn(self.envoy)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the storage switch."""
await self.entity_description.turn_off_fn(self.envoy)
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"]
}
@@ -28,6 +28,7 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
@@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],
+6 -2
View File
@@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(hass, entry_data, cli, device_info)
async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
)
)
else:
bluetooth.async_remove_scanner(hass, device_info.mac_address)
@@ -571,7 +573,9 @@ def _async_setup_device_registry(
configuration_url = None
if device_info.webserver_port > 0:
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
entry_host = entry.data["host"]
host = f"[{entry_host}]" if ":" in entry_host else entry_host
configuration_url = f"http://{host}:{device_info.webserver_port}"
elif (
(dashboard := async_get_dashboard(hass))
and dashboard.data
@@ -18,7 +18,7 @@
"requirements": [
"aioesphomeapi==29.0.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.2.0"
"bleak-esphome==2.7.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -1,33 +1,27 @@
"""The FAA Delays integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import FAADataUpdateCoordinator
from .coordinator import FAAConfigEntry, FAADataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
"""Set up FAA Delays from a config entry."""
code = entry.data[CONF_ID]
coordinator = FAADataUpdateCoordinator(hass, code)
coordinator = FAADataUpdateCoordinator(hass, entry, code)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FAADataUpdateCoordinator
from . import FAAConfigEntry, FAADataUpdateCoordinator
from .const import DOMAIN
@@ -84,10 +83,10 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up a FAA sensor based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
entities = [
FAABinarySensor(coordinator, entry.entry_id, description)
@@ -7,6 +7,7 @@ import logging
from aiohttp import ClientConnectionError
from faadelays import Airport
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -15,14 +16,20 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FAAConfigEntry = ConfigEntry[FAADataUpdateCoordinator]
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
"""Class to manage fetching FAA API data from a single endpoint."""
def __init__(self, hass: HomeAssistant, code: str) -> None:
def __init__(self, hass: HomeAssistant, entry: FAAConfigEntry, code: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(minutes=1),
)
self.session = aiohttp_client.async_get_clientsession(hass)
self.data = Airport(code, self.session)
@@ -4,20 +4,20 @@ from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.start import async_at_started
from .const import DOMAIN, PLATFORMS
from .coordinator import FastdotcomDataUpdateCoordinator
from .const import PLATFORMS
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool:
"""Set up Fast.com from a config entry."""
coordinator = FastdotcomDataUpdateCoordinator(hass)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
coordinator = FastdotcomDataUpdateCoordinator(hass, entry)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(
entry,
@@ -36,8 +36,6 @@ 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: FastdotcomConfigEntry) -> bool:
"""Unload Fast.com config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -6,20 +6,24 @@ from datetime import timedelta
from fastdotcom import fast_com
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER
type FastdotcomConfigEntry = ConfigEntry[FastdotcomDataUpdateCoordinator]
class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]):
"""Class to manage fetching Fast.com data API."""
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, entry: FastdotcomConfigEntry) -> None:
"""Initialize the coordinator for Fast.com."""
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(hours=DEFAULT_INTERVAL),
)
@@ -4,21 +4,13 @@ from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import FastdotcomDataUpdateCoordinator
from .coordinator import FastdotcomConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: FastdotcomConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for the config entry."""
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
return {
"coordinator_data": coordinator.data,
}
return {"coordinator_data": config_entry.runtime_data.data}
@@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfDataRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -15,17 +14,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FastdotcomDataUpdateCoordinator
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: FastdotcomConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fast.com sensor."""
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)])
async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)])
class SpeedtestSensor(CoordinatorEntity[FastdotcomDataUpdateCoordinator], SensorEntity):
@@ -21,9 +21,11 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import raise_if_invalid_filename
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid_hex
DOMAIN = "file_upload"
_DATA: HassKey[FileUploadData] = HassKey(DOMAIN)
ONE_MEGABYTE = 1024 * 1024
MAX_SIZE = 100 * ONE_MEGABYTE
@@ -41,7 +43,7 @@ def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
if DOMAIN not in hass.data:
raise ValueError("File does not exist")
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
if not file_upload_data.has_file(file_id):
raise ValueError("File does not exist")
@@ -149,10 +151,10 @@ class FileUploadView(HomeAssistantView):
hass = request.app[KEY_HASS]
file_id = ulid_hex()
if DOMAIN not in hass.data:
hass.data[DOMAIN] = await FileUploadData.create(hass)
if _DATA not in hass.data:
hass.data[_DATA] = await FileUploadData.create(hass)
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
file_dir = file_upload_data.file_dir(file_id)
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
@@ -206,7 +208,7 @@ class FileUploadView(HomeAssistantView):
raise web.HTTPNotFound
file_id = data["file_id"]
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
if file_upload_data.files.pop(file_id, None) is None:
raise web.HTTPNotFound
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250129.0"]
"requirements": ["home-assistant-frontend==20250203.0"]
}
@@ -244,7 +244,7 @@ class AFSAPIDevice(MediaPlayerEntity):
"""Send volume up command."""
volume = await self.fs_device.get_volume()
volume = int(volume or 0) + 1
await self.fs_device.set_volume(min(volume, self._max_volume))
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
async def async_volume_down(self) -> None:
"""Send volume down command."""
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
value, extra_state_attributes = self.entity_description.state_fn(value)
if self.entity_description.round_state_value:
if TYPE_CHECKING:
assert isinstance(value, int)
value = round_storage(value)
self._attr_native_value = value
@@ -28,14 +28,14 @@
"user": {
"description": "Enter the settings to connect to the camera.",
"data": {
"still_image_url": "Still Image URL (e.g. http://...)",
"stream_source": "Stream Source URL (e.g. rtsp://...)",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"rtsp_transport": "RTSP transport protocol",
"authentication": "Authentication",
"limit_refetch_to_url_change": "Limit refetch to url change",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"framerate": "Frame Rate (Hz)",
"framerate": "Frame rate (Hz)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
+2 -2
View File
@@ -11,7 +11,7 @@ from aiohttp import ClientSession, ClientTimeout, StreamReader
from aiohttp.client_exceptions import ClientError, ClientResponseError
from google_drive_api.api import AbstractAuth, GoogleDriveApi
from homeassistant.components.backup import AgentBackup
from homeassistant.components.backup import AgentBackup, suggested_filename
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.exceptions import (
@@ -132,7 +132,7 @@ class DriveClient:
"""Upload a backup."""
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
backup_metadata = {
"name": f"{backup.name} {backup.date}.tar",
"name": suggested_filename(backup),
"description": json.dumps(backup.as_dict()),
"parents": [folder_id],
"properties": {
@@ -2,6 +2,7 @@
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
@@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
"redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
}
@@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent):
try:
await self._client.async_upload_backup(open_stream, backup)
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
_LOGGER.error("Upload backup error: %s", err)
raise BackupAgentError("Failed to upload backup") from err
raise BackupAgentError(f"Failed to upload backup: {err}") from err
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
try:
return await self._client.async_list_backups()
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
_LOGGER.error("List backups error: %s", err)
raise BackupAgentError("Failed to list backups") from err
raise BackupAgentError(f"Failed to list backups: {err}") from err
async def async_get_backup(
self,
@@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent):
stream = await self._client.async_download(file_id)
return ChunkAsyncStreamIterator(stream)
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
_LOGGER.error("Download backup error: %s", err)
raise BackupAgentError("Failed to download backup") from err
_LOGGER.error("Download backup_id: %s not found", backup_id)
raise BackupAgentError(f"Failed to download backup: {err}") from err
raise BackupAgentError("Backup not found")
async def async_delete_backup(
@@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent):
await self._client.async_delete(file_id)
_LOGGER.debug("Deleted backup_id: %s", backup_id)
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
_LOGGER.error("Delete backup error: %s", err)
raise BackupAgentError("Failed to delete backup") from err
raise BackupAgentError(f"Failed to delete backup: {err}") from err
@@ -35,6 +35,6 @@
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
"description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
}
}
@@ -4,7 +4,7 @@ from __future__ import annotations
import codecs
from collections.abc import Callable
from typing import Any, Literal
from typing import Any, Literal, cast
from google.api_core.exceptions import GoogleAPIError
import google.generativeai as genai
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
return value
def _chat_message_convert(
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
) -> genai_types.ContentDict:
"""Convert any native chat message for this agent to the native format."""
if message.role == "native":
return message.content
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> protos.Content:
"""Create a Google tool response content."""
return protos.Content(
parts=[
protos.Part(
function_response=protos.FunctionResponse(
name=tool_result.tool_name, response=tool_result.tool_result
)
)
for tool_result in content
]
)
role = "model" if message.role == "assistant" else message.role
return {"role": role, "parts": message.content}
def _convert_content(
content: conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent,
) -> genai_types.ContentDict:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
role = "model" if content.role == "assistant" else content.role
return {"role": role, "parts": content.content}
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts = []
if content.content:
parts.append(protos.Part(text=content.content))
if content.tool_calls:
parts.extend(
[
protos.Part(
function_call=protos.FunctionCall(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
)
for tool_call in content.tool_calls
]
)
return protos.Content({"role": "model", "parts": parts})
class GoogleGenerativeAIConversationEntity(
@@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
async with conversation.async_get_chat_session(
self.hass, user_input
) as session:
return await self._async_handle_message(user_input, session)
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
):
return await self._async_handle_message(user_input, chat_log)
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
session: conversation.ChatSession[genai_types.ContentDict],
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Call the API."""
@@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
options = self.entry.options
try:
await session.async_update_llm_data(
await chat_log.async_update_llm_data(
DOMAIN,
user_input,
options.get(CONF_LLM_HASS_API),
@@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
return err.as_conversation_result()
tools: list[dict[str, Any]] | None = None
if session.llm_api:
if chat_log.llm_api:
tools = [
_format_tool(tool, session.llm_api.custom_serializer)
for tool in session.llm_api.tools
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
@@ -249,9 +290,36 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
prompt, *messages = [
_chat_message_convert(message) for message in session.async_get_messages()
]
prompt = chat_log.content[0].content # type: ignore[union-attr]
messages: list[genai_types.ContentDict] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:]:
if chat_content.role == "tool_result":
# mypy doesn't like picking a type based on checking shared property 'role'
tool_results.append(cast(conversation.ToolResultContent, chat_content))
continue
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(
_convert_content(
cast(
conversation.UserContent
| conversation.SystemContent
| conversation.AssistantContent,
chat_content,
)
)
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
model = genai.GenerativeModel(
model_name=model_name,
generation_config={
@@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
),
},
tools=tools or None,
system_instruction=prompt["parts"] if supports_system_instruction else None,
system_instruction=prompt if supports_system_instruction else None,
)
if not supports_system_instruction:
messages = [
{"role": "user", "parts": prompt["parts"]},
{"role": "user", "parts": prompt},
{"role": "model", "parts": "Ok"},
*messages,
]
@@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
content = " ".join(
[part.text.strip() for part in chat_response.parts if part.text]
)
if content:
session.async_add_message(
conversation.Content(
role="assistant",
agent_id=user_input.agent_id,
content=content,
)
)
function_calls = [
part.function_call for part in chat_response.parts if part.function_call
]
if not function_calls or not session.llm_api:
break
tool_responses = []
for function_call in function_calls:
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
tool_calls = []
for part in chat_response.parts:
if not part.function_call:
continue
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"])
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
function_response = await session.async_call_tool(tool_input)
tool_responses.append(
protos.Part(
function_response=protos.FunctionResponse(
name=tool_name, response=function_response
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
chat_request = _create_google_tool_response_content(
[
tool_response
async for tool_response in chat_log.async_add_assistant_content(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=content,
tool_calls=tool_calls or None,
)
)
)
chat_request = protos.Content(parts=tool_responses)
session.async_add_message(
conversation.NativeContent(
agent_id=user_input.agent_id,
content=chat_request,
)
]
)
if not tool_calls:
break
response = intent.IntentResponse(language=user_input.language)
response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
return conversation.ConversationResult(
response=response, conversation_id=session.conversation_id
response=response, conversation_id=chat_log.conversation_id
)
async def _async_entry_update_listener(
@@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.3"]
"requirements": ["habiticalib==0.3.4"]
}
+228 -34
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import logging
from pathlib import Path
import os
from pathlib import Path, PurePath
from typing import Any, cast
from uuid import UUID
from aiohasupervisor import SupervisorClient
from aiohasupervisor.exceptions import (
@@ -25,21 +27,31 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupManagerError,
BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
CreateBackupStage,
CreateBackupState,
Folder,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
async_get_manager as async_get_backup_manager,
suggested_filename as suggested_backup_filename,
suggested_filename_from_name_date,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
@@ -47,6 +59,9 @@ from .handler import get_supervisor_client
LOCATION_CLOUD_BACKUP = ".cloud_backup"
LOCATION_LOCAL = ".local"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
# Set on backups automatically created when updating an addon
TAG_ADDON_UPDATE = "supervisor.addon_update"
_LOGGER = logging.getLogger(__name__)
@@ -97,7 +112,7 @@ def async_register_backup_agents_listener(
def _backup_details_to_agent_backup(
details: supervisor_backups.BackupComplete,
details: supervisor_backups.BackupComplete, location: str | None
) -> AgentBackup:
"""Convert a supervisor backup details object to an agent backup."""
homeassistant_included = details.homeassistant is not None
@@ -109,18 +124,22 @@ def _backup_details_to_agent_backup(
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
for addon in details.addons
]
extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL
return AgentBackup(
addons=addons,
backup_id=details.slug,
database_included=database_included,
date=details.date.isoformat(),
date=extra_metadata.get(
"supervisor.backup_request_date", details.date.isoformat()
),
extra_metadata=details.extra or {},
folders=[Folder(folder) for folder in details.folders],
homeassistant_included=homeassistant_included,
homeassistant_version=details.homeassistant,
name=details.name,
protected=details.protected,
size=details.size_bytes,
protected=details.location_attributes[location].protected,
size=details.location_attributes[location].size_bytes,
)
@@ -144,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(location=self.location),
)
try:
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(
location=self.location
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err
async def async_upload_backup(
self,
@@ -158,8 +182,24 @@ class SupervisorBackupAgent(BackupAgent):
) -> None:
"""Upload a backup.
Not required for supervisor, the SupervisorBackupReaderWriter stores files.
The upload will be skipped if the backup already exists in the agent's location.
"""
if await self.async_get_backup(backup.backup_id):
_LOGGER.debug(
"Backup %s already exists in location %s",
backup.backup_id,
self.location,
)
return
stream = await open_stream()
upload_options = supervisor_backups.UploadBackupOptions(
location={self.location},
filename=PurePath(suggested_backup_filename(backup)),
)
await self._client.backups.upload_backup(
stream,
upload_options,
)
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
@@ -169,7 +209,7 @@ class SupervisorBackupAgent(BackupAgent):
if not backup.locations or self.location not in backup.locations:
continue
details = await self._client.backups.backup_info(backup.slug)
result.append(_backup_details_to_agent_backup(details))
result.append(_backup_details_to_agent_backup(details, self.location))
return result
async def async_get_backup(
@@ -178,10 +218,13 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AgentBackup | None:
"""Return a backup."""
details = await self._client.backups.backup_info(backup_id)
try:
details = await self._client.backups.backup_info(backup_id)
except SupervisorNotFoundError:
return None
if self.location not in details.locations:
return None
return _backup_details_to_agent_backup(details)
return _backup_details_to_agent_backup(details, self.location)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Remove a backup."""
@@ -192,10 +235,6 @@ class SupervisorBackupAgent(BackupAgent):
location={self.location}
),
)
except SupervisorBadRequestError as err:
if err.args[0] != "Backup does not exist":
raise
_LOGGER.debug("Backup %s does not exist", backup_id)
except SupervisorNotFoundError:
_LOGGER.debug("Backup %s does not exist", backup_id)
@@ -246,8 +285,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
for agent_id in agent_ids
if manager.backup_agents[agent_id].domain == DOMAIN
]
locations = [agent.location for agent in hassio_agents]
# Supervisor does not support creating backups spread across multiple
# locations, where some locations are encrypted and some are not.
# It's inefficient to let core do all the copying so we want to let
# supervisor handle as much as possible.
# Therefore, we split the locations into two lists: encrypted and decrypted.
# The longest list will be sent to supervisor, and the remaining locations
# will be handled by async_upload_backup.
# If the lists are the same length, it does not matter which one we send,
# we send the encrypted list to have a well defined behavior.
encrypted_locations: list[str | None] = []
decrypted_locations: list[str | None] = []
agents_settings = manager.config.data.agents
for hassio_agent in hassio_agents:
if password is not None:
if agent_settings := agents_settings.get(hassio_agent.agent_id):
if agent_settings.protected:
encrypted_locations.append(hassio_agent.location)
else:
decrypted_locations.append(hassio_agent.location)
else:
encrypted_locations.append(hassio_agent.location)
else:
decrypted_locations.append(hassio_agent.location)
_LOGGER.debug("Encrypted locations: %s", encrypted_locations)
_LOGGER.debug("Decrypted locations: %s", decrypted_locations)
if hassio_agents:
if len(encrypted_locations) >= len(decrypted_locations):
locations = encrypted_locations
else:
locations = decrypted_locations
password = None
else:
locations = []
locations = locations or [LOCATION_CLOUD_BACKUP]
date = dt_util.now().isoformat()
extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
filename = suggested_filename_from_name_date(backup_name, date)
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
@@ -257,17 +333,21 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
name=backup_name,
password=password,
compressed=True,
location=locations or LOCATION_CLOUD_BACKUP,
location=locations,
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
filename=PurePath(filename),
)
)
except SupervisorError as err:
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
backup_task = self._hass.async_create_task(
self._async_wait_for_backup(
backup, remove_after_upload=not bool(locations)
backup,
locations,
on_progress=on_progress,
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
@@ -276,27 +356,46 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
return (NewBackup(backup_job_id=backup.job_id), backup_task)
async def _async_wait_for_backup(
self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool
self,
backup: supervisor_backups.NewBackup,
locations: list[str | None],
*,
on_progress: Callable[[CreateBackupEvent], None],
remove_after_upload: bool,
) -> WrittenBackup:
"""Wait for a backup to complete."""
backup_complete = asyncio.Event()
backup_id: str | None = None
create_errors: list[dict[str, str]] = []
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup progress."""
nonlocal backup_id
if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown create stage: %s", data.get("stage"))
else:
on_progress(
CreateBackupEvent(
reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS
)
)
if data.get("done") is True:
backup_id = data.get("reference")
create_errors.extend(data.get("errors", []))
backup_complete.set()
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
try:
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
await self._get_job_state(backup.job_id, on_job_progress)
await backup_complete.wait()
finally:
unsub()
if not backup_id:
raise BackupReaderWriterError("Backup failed")
if not backup_id or create_errors:
# We should add more specific error handling here in the future
raise BackupReaderWriterError(
f"Backup failed: {create_errors or 'no backup_id'}"
)
async def open_backup() -> AsyncIterator[bytes]:
try:
@@ -327,7 +426,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
) from err
return WrittenBackup(
backup=_backup_details_to_agent_backup(details),
backup=_backup_details_to_agent_backup(details, locations[0]),
open_stream=open_backup,
release_stream=remove_backup,
)
@@ -347,20 +446,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
for agent_id in agent_ids
if manager.backup_agents[agent_id].domain == DOMAIN
]
locations = {agent.location for agent in hassio_agents}
locations = [agent.location for agent in hassio_agents]
locations = locations or [LOCATION_CLOUD_BACKUP]
backup_id = await self._client.backups.upload_backup(
stream,
supervisor_backups.UploadBackupOptions(
location=locations or {LOCATION_CLOUD_BACKUP}
),
supervisor_backups.UploadBackupOptions(location=set(locations)),
)
async def open_backup() -> AsyncIterator[bytes]:
return await self._client.backups.download_backup(backup_id)
async def remove_backup() -> None:
if locations:
if locations != [LOCATION_CLOUD_BACKUP]:
return
await self._client.backups.remove_backup(
backup_id,
@@ -372,7 +470,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
details = await self._client.backups.backup_info(backup_id)
return WrittenBackup(
backup=_backup_details_to_agent_backup(details),
backup=_backup_details_to_agent_backup(details, locations[0]),
open_stream=open_backup,
release_stream=remove_backup,
)
@@ -436,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
location=restore_location,
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err
except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types
message = err.args[0]
@@ -444,16 +544,30 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
raise HomeAssistantError(message) from err
restore_complete = asyncio.Event()
restore_errors: list[dict[str, str]] = []
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup progress."""
"""Handle backup restore progress."""
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
else:
on_progress(
RestoreBackupEvent(
reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS
)
)
if data.get("done") is True:
restore_complete.set()
restore_errors.extend(data.get("errors", []))
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
try:
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
await self._get_job_state(job.job_id, on_job_progress)
await restore_complete.wait()
if restore_errors:
# We should add more specific error handling here in the future
raise BackupReaderWriterError(f"Restore failed: {restore_errors}")
finally:
unsub()
@@ -463,6 +577,60 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""Check restore status after core restart."""
if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)):
_LOGGER.debug("No restore job ID found in environment")
return
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
sent_event = False
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup restore progress."""
nonlocal sent_event
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
if data.get("done") is not True:
if stage or not sent_event:
sent_event = True
on_progress(
RestoreBackupEvent(
reason=None,
stage=stage,
state=RestoreBackupState.IN_PROGRESS,
)
)
return
restore_errors = data.get("errors", [])
if restore_errors:
_LOGGER.warning("Restore backup failed: %s", restore_errors)
# We should add more specific error handling here in the future
on_progress(
RestoreBackupEvent(
reason="unknown_error",
stage=stage,
state=RestoreBackupState.FAILED,
)
)
else:
on_progress(
RestoreBackupEvent(
reason=None, stage=stage, state=RestoreBackupState.COMPLETED
)
)
on_progress(IdleEvent())
unsub()
unsub = self._async_listen_job_events(restore_job_id, on_job_progress)
try:
await self._get_job_state(restore_job_id, on_job_progress)
except SupervisorError as err:
_LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err)
unsub()
@callback
def _async_listen_job_events(
@@ -491,6 +659,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
)
return unsub
async def _get_job_state(
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
) -> None:
"""Poll a job for its state."""
job = await self._client.jobs.get_job(UUID(job_id))
_LOGGER.debug("Job state: %s", job)
on_event(job.to_dict())
async def _default_agent(client: SupervisorClient) -> str:
"""Return the default agent for creating a backup."""
@@ -515,10 +691,20 @@ async def backup_addon_before_update(
else:
password = None
def addon_update_backup_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return addon update backups."""
return {
backup_id: backup
for backup_id, backup in backups.items()
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
}
try:
await backup_manager.async_create_backup(
agent_ids=[await _default_agent(client)],
extra_metadata={"supervisor.addon_update": addon},
extra_metadata={TAG_ADDON_UPDATE: addon},
include_addons=[addon],
include_all_addons=False,
include_database=False,
@@ -529,6 +715,14 @@ async def backup_addon_before_update(
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error creating backup: {err}") from err
else:
try:
await backup_manager.async_delete_filtered_backups(
include_filter=addon_update_backup_filter,
delete_filter=lambda backups: backups,
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error deleting old backups: {err}") from err
async def backup_core_before_update(hass: HomeAssistant) -> None:
@@ -0,0 +1 @@
"""Virtual integration: Heicko."""
@@ -0,0 +1,6 @@
{
"domain": "heicko",
"name": "Heicko",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}
+163 -187
View File
@@ -2,17 +2,16 @@
from __future__ import annotations
from datetime import timedelta
import logging
import re
from typing import Any, cast
from requests import HTTPError
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_entry_oauth2_flow,
@@ -21,16 +20,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from . import api
from .api import AsyncConfigEntryAuth
from .const import (
ATTR_KEY,
ATTR_PROGRAM,
ATTR_UNIT,
ATTR_VALUE,
BSH_PAUSE,
BSH_RESUME,
DOMAIN,
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
SERVICE_OPTION_ACTIVE,
@@ -44,21 +40,20 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
SCAN_INTERVAL = timedelta(minutes=1)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_SETTING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(SettingKey),
vol.NotIn([SettingKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
}
)
@@ -66,7 +61,10 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
SERVICE_OPTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(OptionKey),
vol.NotIn([OptionKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
vol.Optional(ATTR_UNIT): str,
}
@@ -75,14 +73,23 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
SERVICE_PROGRAM_SCHEMA = vol.Any(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_PROGRAM): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_PROGRAM): vol.All(
vol.Coerce(ProgramKey),
vol.NotIn([ProgramKey.UNKNOWN]),
),
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(OptionKey),
vol.NotIn([OptionKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(int, str),
vol.Optional(ATTR_UNIT): str,
},
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_PROGRAM): str,
vol.Required(ATTR_PROGRAM): vol.All(
vol.Coerce(ProgramKey),
vol.NotIn([ProgramKey.UNKNOWN]),
),
},
)
@@ -99,17 +106,24 @@ PLATFORMS = [
]
def _get_appliance(
hass: HomeAssistant,
device_id: str | None = None,
device_entry: dr.DeviceEntry | None = None,
entry: HomeConnectConfigEntry | None = None,
) -> api.HomeConnectAppliance:
"""Return a Home Connect appliance instance given a device id or a device entry."""
if device_id is not None and device_entry is None:
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
assert device_entry, "Either a device id or a device entry must be provided"
async def _get_client_and_ha_id(
hass: HomeAssistant, device_id: str
) -> tuple[HomeConnectClient, str]:
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError("Device entry not found for device id")
entry: HomeConnectConfigEntry | None = None
for entry_id in device_entry.config_entries:
_entry = hass.config_entries.async_get_entry(entry_id)
assert _entry
if _entry.domain == DOMAIN:
entry = cast(HomeConnectConfigEntry, _entry)
break
if entry is None:
raise ServiceValidationError(
"Home Connect config entry not found for that device id"
)
ha_id = next(
(
@@ -119,158 +133,148 @@ def _get_appliance(
),
None,
)
assert ha_id
def find_appliance(
entry: HomeConnectConfigEntry,
) -> api.HomeConnectAppliance | None:
for device in entry.runtime_data.devices:
appliance = device.appliance
if appliance.haId == ha_id:
return appliance
return None
if entry is None:
for entry_id in device_entry.config_entries:
entry = hass.config_entries.async_get_entry(entry_id)
assert entry
if entry.domain == DOMAIN:
entry = cast(HomeConnectConfigEntry, entry)
if (appliance := find_appliance(entry)) is not None:
return appliance
elif (appliance := find_appliance(entry)) is not None:
return appliance
raise ValueError(f"Appliance for device id {device_entry.id} not found")
def _get_appliance_or_raise_service_validation_error(
hass: HomeAssistant, device_id: str
) -> api.HomeConnectAppliance:
"""Return a Home Connect appliance instance or raise a service validation error."""
try:
return _get_appliance(hass, device_id)
except (ValueError, AssertionError) as err:
if ha_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="appliance_not_found",
translation_placeholders={
"device_id": device_id,
},
) from err
async def _run_appliance_service[*_Ts](
hass: HomeAssistant,
appliance: api.HomeConnectAppliance,
method: str,
*args: *_Ts,
error_translation_key: str,
error_translation_placeholders: dict[str, str],
) -> None:
try:
await hass.async_add_executor_job(getattr(appliance, method), *args)
except api.HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=error_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
**error_translation_placeholders,
},
) from err
)
return entry.runtime_data.client, ha_id
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
async def _async_service_program(call, method):
async def _async_service_program(call: ServiceCall, start: bool):
"""Execute calls to services taking a program."""
program = call.data[ATTR_PROGRAM]
device_id = call.data[ATTR_DEVICE_ID]
options = []
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
option_key = call.data.get(ATTR_KEY)
if option_key is not None:
option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]}
option_unit = call.data.get(ATTR_UNIT)
if option_unit is not None:
option[ATTR_UNIT] = option_unit
options.append(option)
await _run_appliance_service(
hass,
_get_appliance_or_raise_service_validation_error(hass, device_id),
method,
program,
options,
error_translation_key=method,
error_translation_placeholders={
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
},
options = (
[
Option(
option_key,
call.data[ATTR_VALUE],
unit=call.data.get(ATTR_UNIT),
)
]
if option_key is not None
else None
)
async def _async_service_command(call, command):
"""Execute calls to services executing a command."""
device_id = call.data[ATTR_DEVICE_ID]
try:
if start:
await client.start_program(ha_id, program_key=program, options=options)
else:
await client.set_selected_program(
ha_id, program_key=program, options=options
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program" if start else "select_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
},
) from err
appliance = _get_appliance_or_raise_service_validation_error(hass, device_id)
await _run_appliance_service(
hass,
appliance,
"execute_command",
command,
error_translation_key="execute_command",
error_translation_placeholders={"command": command},
)
async def _async_service_key_value(call, method):
"""Execute calls to services taking a key and value."""
key = call.data[ATTR_KEY]
async def _async_service_set_program_options(call: ServiceCall, active: bool):
"""Execute calls to services taking a program."""
option_key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
unit = call.data.get(ATTR_UNIT)
device_id = call.data[ATTR_DEVICE_ID]
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
await _run_appliance_service(
hass,
_get_appliance_or_raise_service_validation_error(hass, device_id),
method,
*((key, value) if unit is None else (key, value, unit)),
error_translation_key=method,
error_translation_placeholders={
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
)
try:
if active:
await client.set_active_program_option(
ha_id,
option_key=option_key,
value=value,
unit=unit,
)
else:
await client.set_selected_program_option(
ha_id,
option_key=option_key,
value=value,
unit=unit,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_options_active_program"
if active
else "set_options_selected_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_KEY: option_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
async def async_service_option_active(call):
async def _async_service_command(call: ServiceCall, command_key: CommandKey):
"""Execute calls to services executing a command."""
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
try:
await client.put_command(ha_id, command_key=command_key, value=True)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="execute_command",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"command": command_key.value,
},
) from err
async def async_service_option_active(call: ServiceCall):
"""Service for setting an option for an active program."""
await _async_service_key_value(call, "set_options_active_program")
await _async_service_set_program_options(call, True)
async def async_service_option_selected(call):
async def async_service_option_selected(call: ServiceCall):
"""Service for setting an option for a selected program."""
await _async_service_key_value(call, "set_options_selected_program")
await _async_service_set_program_options(call, False)
async def async_service_setting(call):
async def async_service_setting(call: ServiceCall):
"""Service for changing a setting."""
await _async_service_key_value(call, "set_setting")
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
async def async_service_pause_program(call):
try:
await client.set_setting(ha_id, setting_key=key, value=value)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_setting",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
async def async_service_pause_program(call: ServiceCall):
"""Service for pausing a program."""
await _async_service_command(call, BSH_PAUSE)
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
async def async_service_resume_program(call):
async def async_service_resume_program(call: ServiceCall):
"""Service for resuming a paused program."""
await _async_service_command(call, BSH_RESUME)
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
async def async_service_select_program(call):
async def async_service_select_program(call: ServiceCall):
"""Service for selecting a program."""
await _async_service_program(call, "select_program")
await _async_service_program(call, False)
async def async_service_start_program(call):
async def async_service_start_program(call: ServiceCall):
"""Service for starting a program."""
await _async_service_program(call, "start_program")
await _async_service_program(call, True)
hass.services.async_register(
DOMAIN,
@@ -323,12 +327,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
)
)
entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
await update_all_devices(hass, entry)
config_entry_auth = AsyncConfigEntryAuth(hass, session)
home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.runtime_data.start_event_listener()
return True
@@ -339,21 +352,6 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@Throttle(SCAN_INTERVAL)
async def update_all_devices(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> None:
"""Update all the devices."""
hc_api = entry.runtime_data
try:
await hass.async_add_executor_job(hc_api.get_devices)
for device in hc_api.devices:
await hass.async_add_executor_job(device.initialize)
except HTTPError as err:
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
async def async_migrate_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
@@ -382,25 +380,3 @@ async def async_migrate_entry(
_LOGGER.debug("Migration to version %s successful", entry.version)
return True
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
"""Return a dict from a Home Connect error."""
return {
"description": cast(dict[str, Any], err.args[0]).get("description", "?")
if len(err.args) > 0 and isinstance(err.args[0], dict)
else err.args[0]
if len(err.args) > 0 and isinstance(err.args[0], str)
else "?",
}
def bsh_key_to_translation_key(bsh_key: str) -> str:
"""Convert a BSH key to a translation key format.
This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
"""
return "_".join(
RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
).lower()
+11 -68
View File
@@ -1,85 +1,28 @@
"""API for Home Connect bound to HASS OAuth."""
from asyncio import run_coroutine_threadsafe
import logging
from aiohomeconnect.client import AbstractAuth
from aiohomeconnect.const import API_ENDPOINT
import homeconnect
from homeconnect.api import HomeConnectAppliance, HomeConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES
_LOGGER = logging.getLogger(__name__)
from homeassistant.helpers.httpx_client import get_async_client
class ConfigEntryAuth(homeconnect.HomeConnectAPI):
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Home Connect authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Home Connect Auth."""
self.hass = hass
self.config_entry = config_entry
self.session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
super().__init__(self.session.token)
self.devices: list[HomeConnectDevice] = []
super().__init__(get_async_client(hass), host=API_ENDPOINT)
self.session = oauth_session
def refresh_tokens(self) -> dict:
"""Refresh and return new Home Connect tokens using Home Assistant OAuth2 session."""
run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self.hass.loop
).result()
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self.session.async_ensure_token_valid()
return self.session.token
def get_devices(self) -> list[HomeConnectAppliance]:
"""Get a dictionary of devices."""
appl: list[HomeConnectAppliance] = self.get_appliances()
self.devices = [HomeConnectDevice(self.hass, app) for app in appl]
return self.devices
class HomeConnectDevice:
"""Generic Home Connect device."""
def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None:
"""Initialize the device class."""
self.hass = hass
self.appliance = appliance
def initialize(self) -> None:
"""Fetch the info needed to initialize the device."""
try:
self.appliance.get_status()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch appliance status. Probably offline")
try:
self.appliance.get_settings()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch settings. Probably offline")
try:
program_active = self.appliance.get_programs_active()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch active programs. Probably offline")
program_active = None
if program_active and ATTR_KEY in program_active:
self.appliance.status[BSH_ACTIVE_PROGRAM] = {
ATTR_VALUE: program_active[ATTR_KEY]
}
self.appliance.listen_events(callback=self.event_callback)
def event_callback(self, appliance: HomeConnectAppliance) -> None:
"""Handle event."""
_LOGGER.debug("Update triggered on %s", appliance.name)
_LOGGER.debug(self.appliance.status)
dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId)
return self.session.token["access_token"]
@@ -1,10 +1,10 @@
"""Application credentials platform for Home Connect."""
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
@@ -1,7 +1,9 @@
"""Provides a binary sensor for Home Connect."""
from dataclasses import dataclass
import logging
from typing import cast
from aiohomeconnect.model import StatusKey
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
@@ -19,26 +21,22 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from . import HomeConnectConfigEntry
from .api import HomeConnectDevice
from .common import setup_home_connect_entry
from .const import (
ATTR_VALUE,
BSH_DOOR_STATE,
BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN,
BSH_REMOTE_CONTROL_ACTIVATION_STATE,
BSH_REMOTE_START_ALLOWANCE_STATE,
DOMAIN,
REFRIGERATION_STATUS_DOOR_CHILLER,
REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_FREEZER,
REFRIGERATION_STATUS_DOOR_OPEN,
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
REFRIGERATION_DOOR_BOOLEAN_MAP = {
REFRIGERATION_STATUS_DOOR_CLOSED: False,
REFRIGERATION_STATUS_DOOR_OPEN: True,
@@ -54,19 +52,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription):
BINARY_SENSORS = (
HomeConnectBinarySensorEntityDescription(
key=BSH_REMOTE_CONTROL_ACTIVATION_STATE,
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
translation_key="remote_control",
),
HomeConnectBinarySensorEntityDescription(
key=BSH_REMOTE_START_ALLOWANCE_STATE,
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
translation_key="remote_start",
),
HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.LocalControlActive",
key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE,
translation_key="local_control",
),
HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.BatteryChargingState",
key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
boolean_map={
"BSH.Common.EnumType.BatteryChargingState.Charging": True,
@@ -75,7 +73,7 @@ BINARY_SENSORS = (
translation_key="battery_charging_state",
),
HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.ChargingConnection",
key=StatusKey.BSH_COMMON_CHARGING_CONNECTION,
device_class=BinarySensorDeviceClass.PLUG,
boolean_map={
"BSH.Common.EnumType.ChargingConnection.Connected": True,
@@ -84,31 +82,31 @@ BINARY_SENSORS = (
translation_key="charging_connection",
),
HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted",
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
translation_key="dust_box_inserted",
),
HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.Lifted",
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED,
translation_key="lifted",
),
HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.Lost",
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
translation_key="lost",
),
HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_CHILLER,
key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="chiller_door",
),
HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_FREEZER,
key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="freezer_door",
),
HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="refrigerator_door",
@@ -116,26 +114,33 @@ BINARY_SENSORS = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS
if description.key in appliance.status
)
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
def get_entities() -> list[BinarySensorEntity]:
entities: list[BinarySensorEntity] = []
for device in entry.runtime_data.devices:
entities.extend(
HomeConnectBinarySensor(device, description)
for description in BINARY_SENSORS
if description.key in device.appliance.status
)
if BSH_DOOR_STATE in device.appliance.status:
entities.append(HomeConnectDoorBinarySensor(device))
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
@@ -143,25 +148,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
entity_description: HomeConnectBinarySensorEntityDescription
@property
def available(self) -> bool:
"""Return true if the binary sensor is available."""
return self._attr_is_on is not None
async def async_update(self) -> None:
"""Update the binary sensor's status."""
if not self.device.appliance.status or not (
status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
):
self._attr_is_on = None
return
if self.entity_description.boolean_map:
self._attr_is_on = self.entity_description.boolean_map.get(status)
elif status not in [True, False]:
self._attr_is_on = None
else:
def update_native_value(self) -> None:
"""Set the native value of the binary sensor."""
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
if isinstance(status, bool):
self._attr_is_on = status
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
elif self.entity_description.boolean_map:
self._attr_is_on = self.entity_description.boolean_map.get(status)
else:
self._attr_is_on = None
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
@@ -171,13 +166,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
def __init__(
self,
device: HomeConnectDevice,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None:
"""Initialize the entity."""
super().__init__(
device,
coordinator,
appliance,
HomeConnectBinarySensorEntityDescription(
key=BSH_DOOR_STATE,
key=StatusKey.BSH_COMMON_DOOR_STATE,
device_class=BinarySensorDeviceClass.DOOR,
boolean_map={
BSH_DOOR_STATE_CLOSED: False,
@@ -186,8 +183,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
},
),
)
self._attr_unique_id = f"{device.appliance.haId}-Door"
self._attr_name = f"{device.appliance.name} Door"
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
self._attr_name = f"{appliance.info.name} Door"
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
@@ -234,6 +231,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
)
@@ -0,0 +1,99 @@
"""Common callbacks for all Home Connect platforms."""
from collections.abc import Callable
from functools import partial
from typing import cast
from aiohomeconnect.model import EventKey
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
def _handle_paired_or_connected_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Handle a new paired appliance or an appliance that has been connected.
This function is used to handle connected events also, because some appliances
don't report any data while they are off because they disconnect themselves
when they are turned off, so we need to check if the entities have been added
already or it is the first time we see them when the appliance is connected.
"""
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = [
entity
for entity in get_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
]
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
)
entities.extend(entities_to_add)
async_add_entities(entities)
def _handle_depaired_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
known_entity_unique_ids.pop(entity_unique_id, None)
def setup_home_connect_entry(
entry: HomeConnectConfigEntry,
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the callbacks for paired and depaired appliances."""
known_entity_unique_ids: dict[str, str] = {}
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = get_entities_for_appliance(entry, appliance)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
)
entities.extend(entities_to_add)
async_add_entities(entities)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
partial(
_handle_paired_or_connected_appliance,
entry,
known_entity_unique_ids,
get_entities_for_appliance,
async_add_entities,
),
(
EventKey.BSH_COMMON_APPLIANCE_PAIRED,
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
),
)
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
)
)

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