Compare commits

..

245 Commits

Author SHA1 Message Date
mib1185 40fa4721ed improve backup filenames 2025-02-04 19:56:23 +00: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
509 changed files with 15484 additions and 10297 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
@@ -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)
+1 -11
View File
@@ -19,20 +19,10 @@ class ApSystemsEntity(Entity):
data: ApSystemsData,
) -> None:
"""Initialize the APsystems entity."""
# Handle device version safely
sw_version = None
if data.coordinator.device_version:
version_parts = data.coordinator.device_version.split(" ")
if len(version_parts) > 1:
sw_version = version_parts[1]
else:
sw_version = version_parts[0]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
manufacturer="APsystems",
model="EZ1-M",
serial_number=data.device_id,
sw_version=sw_version,
sw_version=data.coordinator.device_version.split(" ")[1],
)
@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aranet4==2.5.1"]
"requirements": ["aranet4==2.5.0"]
}
@@ -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:
@@ -1404,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."""
@@ -1425,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."""
@@ -1436,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
@@ -8,7 +8,7 @@ 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 conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
@@ -28,14 +28,12 @@ from homeassistant.components.tts import (
)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity
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__)
@@ -114,7 +112,6 @@ 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
@@ -260,8 +257,27 @@ class AssistSatelliteEntity(entity.Entity):
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
@@ -325,51 +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,
conversation_extra_system_prompt=extra_system_prompt,
),
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."""
@@ -393,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
@@ -1,7 +1,5 @@
"""Assist Satellite intents."""
from typing import Final
import voluptuous as vol
from homeassistant.core import HomeAssistant
@@ -9,8 +7,6 @@ from homeassistant.helpers import entity_registry as er, intent
from .const import DOMAIN, AssistSatelliteEntityFeature
EXCLUDED_DOMAINS: Final[set[str]] = {"voip"}
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the intents."""
@@ -34,36 +30,19 @@ class BroadcastIntentHandler(intent.IntentHandler):
ent_reg = er.async_get(hass)
# Find all assist satellite entities that are not the one invoking the intent
entities: dict[str, er.RegistryEntry] = {}
for entity in hass.states.async_entity_ids(DOMAIN):
entry = ent_reg.async_get(entity)
if (
(entry is None)
or (
# Supports announce
not (
entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE
)
)
# Not the invoking device
or (intent_obj.device_id and (entry.device_id == intent_obj.device_id))
):
# Skip satellite
continue
entities = {
entity: entry
for entity in hass.states.async_entity_ids(DOMAIN)
if (entry := ent_reg.async_get(entity))
and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE
}
# Check domain of config entry against excluded domains
if (
entry.config_entry_id
and (
config_entry := hass.config_entries.async_get_entry(
entry.config_entry_id
)
)
and (config_entry.domain in EXCLUDED_DOMAINS)
):
continue
entities[entity] = entry
if intent_obj.device_id:
entities = {
entity: entry
for entity, entry in entities.items()
if entry.device_id != intent_obj.device_id
}
await hass.services.async_call(
DOMAIN,
@@ -75,6 +54,7 @@ class BroadcastIntentHandler(intent.IntentHandler):
)
response = intent_obj.create_response()
response.async_set_speech("Done")
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
success_results=[
@@ -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",
@@ -27,8 +27,8 @@
}
},
"start_conversation": {
"name": "Start Conversation",
"description": "Start a conversation from a satellite.",
"name": "Start conversation",
"description": "Starts a conversation from a satellite.",
"fields": {
"start_message": {
"name": "Message",
+1 -2
View File
@@ -37,7 +37,7 @@ from .manager import (
RestoreBackupState,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .models import AddonInfo, AgentBackup, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
@@ -48,7 +48,6 @@ __all__ = [
"BackupAgentError",
"BackupAgentPlatformProtocol",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
+13 -1
View File
@@ -11,7 +11,13 @@ from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupAgentError
from .models import AgentBackup, BackupError
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupAgentUnreachableError(BackupAgentError):
@@ -21,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError):
_message = "The backup agent is unreachable."
class BackupNotFound(BackupAgentError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"
class BackupAgent(abc.ABC):
"""Backup agent interface."""
+2 -2
View File
@@ -11,9 +11,9 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound
from .models import AgentBackup
from .util import read_backup, suggested_filename
+6 -10
View File
@@ -21,7 +21,6 @@ from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import BackupNotFound
@callback
@@ -70,16 +69,13 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
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
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
async def _send_backup_no_password(
self,
+49 -142
View File
@@ -4,13 +4,11 @@ from __future__ import annotations
import abc
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Coroutine
from dataclasses import dataclass, replace
from enum import StrEnum
import hashlib
import io
from itertools import chain
import json
from pathlib import Path, PurePath
import shutil
@@ -52,14 +50,7 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .store import BackupStore
from .util import (
AsyncIteratorReader,
@@ -283,6 +274,12 @@ 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."""
@@ -561,15 +558,8 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(list_backups_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
continue
if isinstance(result, Exception):
agent_errors[agent_id] = result
LOGGER.error(
"Unexpected error for %s: %s", agent_id, result, exc_info=result
)
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
@@ -596,7 +586,7 @@ class BackupManager:
name=agent_backup.name,
with_automatic_settings=with_automatic_settings,
)
backups[backup_id].agents[agent_id] = AgentBackupStatus(
backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
protected=agent_backup.protected,
size=agent_backup.size,
)
@@ -619,15 +609,8 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(get_backup_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
continue
if isinstance(result, Exception):
agent_errors[agent_id] = result
LOGGER.error(
"Unexpected error for %s: %s", agent_id, result, exc_info=result
)
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
@@ -655,7 +638,7 @@ class BackupManager:
name=result.name,
with_automatic_settings=with_automatic_settings,
)
backup.agents[agent_id] = AgentBackupStatus(
backup.agents[agent_ids[idx]] = AgentBackupStatus(
protected=result.protected,
size=result.size,
)
@@ -678,31 +661,21 @@ class BackupManager:
return None
return with_automatic_settings
async def async_delete_backup(
self, backup_id: str, *, agent_ids: list[str] | None = None
) -> dict[str, Exception]:
async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]:
"""Delete a backup."""
agent_errors: dict[str, Exception] = {}
if agent_ids is None:
agent_ids = list(self.backup_agents)
agent_ids = list(self.backup_agents)
delete_backup_results = await asyncio.gather(
*(
self.backup_agents[agent_id].async_delete_backup(backup_id)
for agent_id in agent_ids
agent.async_delete_backup(backup_id)
for agent in self.backup_agents.values()
),
return_exceptions=True,
)
for idx, result in enumerate(delete_backup_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
continue
if isinstance(result, Exception):
agent_errors[agent_id] = result
LOGGER.error(
"Unexpected error for %s: %s", agent_id, result, exc_info=result
)
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, BaseException):
raise result # unexpected error
@@ -735,71 +708,35 @@ class BackupManager:
# Run the include filter first to ensure we only consider backups that
# should be included in the deletion process.
backups = include_filter(backups)
backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict)
for backup_id, backup in backups.items():
for agent_id in backup.agents:
backups_by_agent[agent_id][backup_id] = backup
LOGGER.debug("Backups returned by include filter: %s", backups)
LOGGER.debug(
"Backups returned by include filter by agent: %s",
{agent_id: list(backups) for agent_id, backups in backups_by_agent.items()},
)
LOGGER.debug("Total automatic backups: %s", backups)
backups_to_delete = delete_filter(backups)
LOGGER.debug("Backups returned by delete filter: %s", backups_to_delete)
if not backups_to_delete:
return
# always delete oldest backup first
backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(
dict
)
for backup_id, backup in sorted(
backups_to_delete.items(),
key=lambda backup_item: backup_item[1].date,
):
for agent_id in backup.agents:
backups_to_delete_by_agent[agent_id][backup_id] = backup
LOGGER.debug(
"Backups returned by delete filter by agent: %s",
{
agent_id: list(backups)
for agent_id, backups in backups_to_delete_by_agent.items()
},
)
for agent_id, to_delete_from_agent in backups_to_delete_by_agent.items():
if len(to_delete_from_agent) >= len(backups_by_agent[agent_id]):
# Never delete the last backup.
last_backup = to_delete_from_agent.popitem()
LOGGER.debug(
"Keeping the last backup %s for agent %s", last_backup, agent_id
)
LOGGER.debug(
"Backups to delete by agent: %s",
{
agent_id: list(backups)
for agent_id, backups in backups_to_delete_by_agent.items()
},
backups_to_delete = dict(
sorted(
backups_to_delete.items(),
key=lambda backup_item: backup_item[1].date,
)
)
backup_ids_to_delete: dict[str, set[str]] = defaultdict(set)
for agent_id, to_delete in backups_to_delete_by_agent.items():
for backup_id in to_delete:
backup_ids_to_delete[backup_id].add(agent_id)
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)
if not backup_ids_to_delete:
LOGGER.debug("Backups to delete: %s", backups_to_delete)
if not backups_to_delete:
return
backup_ids = list(backup_ids_to_delete)
backup_ids = list(backups_to_delete)
delete_results = await asyncio.gather(
*(
self.async_delete_backup(backup_id, agent_ids=list(agent_ids))
for backup_id, agent_ids in backup_ids_to_delete.items()
)
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
)
agent_errors = {
backup_id: error
@@ -889,7 +826,7 @@ class BackupManager:
password=None,
)
await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors, [])
self.known_backups.add(written_backup.backup, agent_errors)
return written_backup.backup.backup_id
async def async_create_backup(
@@ -1013,23 +950,12 @@ class BackupManager:
with_automatic_settings: bool,
) -> NewBackup:
"""Initiate generating a backup."""
unavailable_agents = [
if not agent_ids:
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
]
if not (
available_agents := [
agent_id for agent_id in agent_ids if agent_id in self.backup_agents
]
):
raise BackupManagerError(
f"At least one available backup agent must be selected, got {agent_ids}"
)
if unavailable_agents:
LOGGER.warning(
"Backup agents %s are not available, will backupp to %s",
unavailable_agents,
available_agents,
)
]:
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
if include_all_addons and include_addons:
raise BackupManagerError(
"Cannot include all addons and specify specific addons"
@@ -1046,7 +972,7 @@ class BackupManager:
new_backup,
self._backup_task,
) = await self._reader_writer.async_create_backup(
agent_ids=available_agents,
agent_ids=agent_ids,
backup_name=backup_name,
extra_metadata=extra_metadata
| {
@@ -1065,9 +991,7 @@ class BackupManager:
raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(
available_agents, unavailable_agents, with_automatic_settings, password
),
self._async_finish_backup(agent_ids, with_automatic_settings, password),
name="backup_manager_finish_backup",
)
if not raise_task_error:
@@ -1084,11 +1008,7 @@ class BackupManager:
return new_backup
async def _async_finish_backup(
self,
available_agents: list[str],
unavailable_agents: list[str],
with_automatic_settings: bool,
password: str | None,
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None
) -> None:
"""Finish a backup."""
if TYPE_CHECKING:
@@ -1107,7 +1027,7 @@ class BackupManager:
LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s",
written_backup.backup.backup_id,
available_agents,
agent_ids,
)
self.async_on_backup_event(
CreateBackupEvent(
@@ -1120,15 +1040,13 @@ class BackupManager:
try:
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=available_agents,
agent_ids=agent_ids,
open_stream=written_backup.open_stream,
password=password,
)
finally:
await written_backup.release_stream()
self.known_backups.add(
written_backup.backup, agent_errors, unavailable_agents
)
self.known_backups.add(written_backup.backup, agent_errors)
if not agent_errors:
if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
@@ -1137,7 +1055,7 @@ class BackupManager:
backup_success = True
if with_automatic_settings:
self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
self._update_issue_after_agent_upload(agent_errors)
# delete old backups more numerous than copies
# try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self)
@@ -1297,10 +1215,10 @@ class BackupManager:
)
def _update_issue_after_agent_upload(
self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
self, agent_errors: dict[str, Exception]
) -> None:
"""Update issue registry after a backup is uploaded to agents."""
if not agent_errors and not unavailable_agents:
if not agent_errors:
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
return
ir.async_create_issue(
@@ -1314,13 +1232,7 @@ class BackupManager:
translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={
"failed_agents": ", ".join(
chain(
(
self.backup_agents[agent_id].name
for agent_id in agent_errors
),
unavailable_agents,
)
self.backup_agents[agent_id].name for agent_id in agent_errors
)
},
)
@@ -1389,12 +1301,11 @@ class KnownBackups:
self,
backup: AgentBackup,
agent_errors: dict[str, Exception],
unavailable_agents: list[str],
) -> None:
"""Add a backup."""
self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id,
failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
failed_agent_ids=list(agent_errors),
)
self._manager.store.save()
@@ -1500,11 +1411,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER]
agent_config = manager.config.data.agents.get(self._local_agent_id)
if (
self._local_agent_id in agent_ids
and agent_config
and not agent_config.protected
):
if agent_config and not agent_config.protected:
password = None
backup = AgentBackup(
-18
View File
@@ -77,25 +77,7 @@ 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"
+1 -1
View File
@@ -122,7 +122,7 @@ 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())
return "_".join(f"{name}-{date.strftime('%Y-%m-%d %H-%M-%S')}.tar".split())
def suggested_filename(backup: AgentBackup) -> str:
+1 -5
View File
@@ -15,7 +15,7 @@ from .manager import (
IncorrectPasswordError,
ManagerStateEvent,
)
from .models import BackupNotFound, Folder
from .models import Folder
@callback
@@ -151,8 +151,6 @@ 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:
@@ -181,8 +179,6 @@ 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:
@@ -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",
@@ -5,7 +5,7 @@ from __future__ import annotations
import datetime
import logging
import platform
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import (
@@ -302,6 +302,7 @@ async def async_update_device(
entry: ConfigEntry,
adapter: str,
details: AdapterDetails,
via_device_domain: str | None = None,
via_device_id: str | None = None,
) -> None:
"""Update device registry entry.
@@ -321,11 +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 and (via_device_entry := device_registry.async_get(via_device_id)):
kwargs: dict[str, Any] = {"via_device_id": via_device_id}
if not device_entry.area_id and via_device_entry.area_id:
kwargs["area_id"] = via_device_entry.area_id
device_registry.async_update_device(device_entry.id, **kwargs)
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:
@@ -360,6 +360,7 @@ 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
@@ -140,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
title=adapter_title(adapter, details), data={}
)
configured_addresses = self._async_current_ids(include_ignore=False)
configured_addresses = self._async_current_ids()
bluetooth_adapters = get_adapters()
await bluetooth_adapters.refresh()
self._adapters = bluetooth_adapters.adapters
@@ -155,8 +155,12 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
]
if not unconfigured_adapters:
ignored_adapters = len(
self._async_current_entries(include_ignore=True)
) - len(self._async_current_entries(include_ignore=False))
return self.async_abort(
reason="no_adapters",
description_placeholders={"ignored_adapters": str(ignored_adapters)},
)
if len(unconfigured_adapters) == 1:
self._adapter = list(self._adapters)[0]
@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.8.1",
"bluetooth-adapters==0.21.4",
"bleak-retry-connector==3.8.0",
"bluetooth-adapters==0.21.1",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.21.1"
"bluetooth-data-tools==1.23.3",
"dbus-fast==2.32.0",
"habluetooth==3.21.0"
]
}
+1 -1
View File
@@ -411,7 +411,7 @@ def ble_device_matches(
) and service_data_uuid not in service_info.service_data:
return False
if (manufacturer_id := matcher.get(MANUFACTURER_ID)) is not None:
if manufacturer_id := matcher.get(MANUFACTURER_ID):
if manufacturer_id not in service_info.manufacturer_data:
return False
@@ -23,7 +23,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"no_adapters": "No unconfigured Bluetooth adapters found."
"no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters."
}
},
"options": {
+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)
@@ -67,6 +67,11 @@ 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,
+79 -32
View File
@@ -3,16 +3,21 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
import random
from typing import Any
from aiohttp import ClientError
from aiohttp import ClientError, ClientTimeout
from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.api import CloudApiNonRetryableError
from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
from hass_nabucasa.cloud_api import (
async_files_delete_file,
async_files_download_details,
async_files_list,
async_files_upload_details,
)
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
@@ -23,11 +28,20 @@ from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
"""Calculate the MD5 hash of a file."""
file_hash = hashlib.md5()
async for chunk in stream:
file_hash.update(chunk)
return base64.b64encode(file_hash.digest()).decode()
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
@@ -95,14 +109,63 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Backup not found")
try:
content = await self._cloud.files.download(
storage_type=StorageType.BACKUP,
details = await async_files_download_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
)
except CloudError as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get download details") from err
return ChunkAsyncStreamIterator(content)
try:
resp = await self._cloud.websession.get(
details["url"],
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
resp.raise_for_status()
except ClientError as err:
raise BackupAgentError("Failed to download backup") from err
return ChunkAsyncStreamIterator(resp.content)
async def _async_do_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
filename: str,
base64md5hash: str,
metadata: dict[str, Any],
size: int,
) -> None:
"""Upload a backup."""
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=filename,
metadata=metadata,
size=size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_upload_backup(
self,
@@ -119,19 +182,15 @@ class CloudBackupAgent(BackupAgent):
if not backup.protected:
raise BackupAgentError("Cloud backups must be protected")
size = backup.size
try:
base64md5hash = await calculate_b64md5(open_stream, size)
except FilesError as err:
raise BackupAgentError(err) from err
base64md5hash = await _b64md5(await open_stream())
filename = self._get_backup_filename()
metadata = backup.as_dict()
size = backup.size
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._cloud.files.upload(
storage_type=StorageType.BACKUP,
await self._async_do_upload_backup(
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
@@ -139,19 +198,9 @@ class CloudBackupAgent(BackupAgent):
size=size,
)
break
except CloudApiNonRetryableError as err:
if err.code == "NC-SH-FH-03":
raise BackupAgentError(
translation_domain=DOMAIN,
translation_key="backup_size_too_large",
translation_placeholders={
"size": str(round(size / (1024**3), 2))
},
) from err
raise BackupAgentError(f"Failed to upload backup {err}") from err
except CloudError as err:
except BackupAgentError as err:
if tries == _RETRY_LIMIT:
raise BackupAgentError(f"Failed to upload backup {err}") from err
raise
tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info(
@@ -178,7 +227,7 @@ class CloudBackupAgent(BackupAgent):
try:
await async_files_delete_file(
self._cloud,
storage_type=StorageType.BACKUP,
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
)
except (ClientError, CloudError) as err:
@@ -187,9 +236,7 @@ class CloudBackupAgent(BackupAgent):
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
try:
backups = await async_files_list(
self._cloud, storage_type=StorageType.BACKUP
)
backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
_LOGGER.debug("Cloud backups: %s", backups)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.92.0"],
"requirements": ["hass-nabucasa==0.89.0"],
"single_config_entry": true
}
@@ -17,11 +17,6 @@
"subscription_expiration": "Subscription expiration"
}
},
"exceptions": {
"backup_size_too_large": {
"message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud."
}
},
"issues": {
"deprecated_gender": {
"title": "The {deprecated_option} text-to-speech option is deprecated",
+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,
@@ -140,10 +140,8 @@ def get_accounts(client, version):
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
API_ACCOUNT_AMOUNT: (
float(account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE])
+ float(account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE])
),
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
}
for account in accounts
@@ -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(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
"requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"]
}
@@ -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
@@ -44,7 +44,9 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]):
)
except InvalidLogin as err:
raise ConfigEntryAuthFailed(
"Auth expired while fetching last reading"
f"Auth expired while fetching last reading for meter {self.meter.meter_id}"
) from err
except (HTTPError, DiscovergyClientError) as err:
raise UpdateFailed(f"Error while fetching last reading: {err}") from err
raise UpdateFailed(
f"Error while fetching last reading for meter {self.meter.meter_id}"
) from err
@@ -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)
+4 -9
View File
@@ -23,7 +23,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from . import EconetConfigEntry
from .const import DOMAIN
@@ -35,13 +35,8 @@ ECONET_STATE_TO_HA = {
ThermostatOperationMode.OFF: HVACMode.OFF,
ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL,
ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY,
ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT,
}
HA_STATE_TO_ECONET = {
value: key
for key, value in ECONET_STATE_TO_HA.items()
if key != ThermostatOperationMode.EMERGENCY_HEAT
}
HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()}
ECONET_FAN_STATE_TO_HA = {
ThermostatFanMode.AUTO: FAN_AUTO,
@@ -214,7 +209,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
create_issue(
async_create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
@@ -228,7 +223,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
create_issue(
async_create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
@@ -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==12.1.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"]
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
"requirements": ["eheimdigital==1.0.6"],
"requirements": ["eheimdigital==1.0.5"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]
@@ -4,16 +4,12 @@ from __future__ import annotations
import aiohttp
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.exceptions import ApiException
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
entity_registry as er,
)
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api
from .coordinator import (
@@ -48,9 +44,7 @@ async def async_setup_entry(
raise ConfigEntryNotReady from err
ek_api = ElectricKiwiApi(
api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api)
account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api)
@@ -59,8 +53,6 @@ async def async_setup_entry(
await ek_api.set_active_session()
await hop_coordinator.async_config_entry_first_refresh()
await account_coordinator.async_config_entry_first_refresh()
except AuthException as err:
raise ConfigEntryAuthFailed from err
except ApiException as err:
raise ConfigEntryNotReady from err
@@ -78,53 +70,3 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version == 1 and config_entry.minor_version == 1:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
ek_api = ElectricKiwiApi(
api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
)
try:
await ek_api.set_active_session()
connection_details = await ek_api.get_connection_details()
except AuthException:
config_entry.async_start_reauth(hass)
return False
except ApiException:
return False
unique_id = str(ek_api.customer_number)
identifier = ek_api.electricity.identifier
hass.config_entries.async_update_entry(
config_entry, unique_id=unique_id, minor_version=2
)
entity_registry = er.async_get(hass)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for entity in entity_entries:
assert entity.config_entry_id
entity_registry.async_update_entity(
entity.entity_id,
new_unique_id=entity.unique_id.replace(
f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}"
),
)
return True
+5 -21
View File
@@ -2,16 +2,17 @@
from __future__ import annotations
from typing import cast
from aiohttp import ClientSession
from electrickiwi_api import AbstractAuth
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import config_entry_oauth2_flow
from .const import API_BASE_URL
class ConfigEntryElectricKiwiAuth(AbstractAuth):
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config entry."""
def __init__(
@@ -28,21 +29,4 @@ class ConfigEntryElectricKiwiAuth(AbstractAuth):
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return str(self._oauth_session.token["access_token"])
class ConfigFlowElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config flow."""
def __init__(
self,
hass: HomeAssistant,
token: str,
) -> None:
"""Initialize ConfigFlowFitbitApi."""
super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the token for the Electric Kiwi API."""
return self._token
return cast(str, self._oauth_session.token["access_token"])
@@ -6,14 +6,9 @@ from collections.abc import Mapping
import logging
from typing import Any
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN, SCOPE_VALUES
@@ -22,8 +17,6 @@ class ElectricKiwiOauth2FlowHandler(
):
"""Config flow to handle Electric Kiwi OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@@ -47,30 +40,12 @@ class ElectricKiwiOauth2FlowHandler(
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: self._get_reauth_entry().title},
)
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for Electric Kiwi."""
ek_api = ElectricKiwiApi(
api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"])
)
try:
session = await ek_api.get_active_session()
except ApiException:
return self.async_abort(reason="connection_error")
unique_id = str(session.data.customer_number)
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=unique_id, data=data)
existing_entry = await self.async_set_unique_id(DOMAIN)
if existing_entry:
return self.async_update_reload_and_abort(existing_entry, data=data)
return await super().async_oauth_create_entry(data)
@@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize"
OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session"
@@ -10,7 +10,7 @@ import logging
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import AccountSummary, Hop, HopIntervals
from electrickiwi_api.model import AccountBalance, Hop, HopIntervals
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData:
type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData]
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]):
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
"""ElectricKiwi Account Data object."""
def __init__(
@@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]):
name="Electric Kiwi Account Data",
update_interval=ACCOUNT_SCAN_INTERVAL,
)
self.ek_api = ek_api
self._ek_api = ek_api
async def _async_update_data(self) -> AccountSummary:
async def _async_update_data(self) -> AccountBalance:
"""Fetch data from Account balance API endpoint."""
try:
async with asyncio.timeout(60):
return await self.ek_api.get_account_summary()
return await self._ek_api.get_account_balance()
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
@@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
# Polling interval. Will only be polled if there are subscribers.
update_interval=HOP_SCAN_INTERVAL,
)
self.ek_api = ek_api
self._ek_api = ek_api
self.hop_intervals: HopIntervals | None = None
def get_hop_options(self) -> dict[str, int]:
@@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
async def async_update_hop(self, hop_interval: int) -> Hop:
"""Update selected hop and data."""
try:
self.async_set_updated_data(await self.ek_api.post_hop(hop_interval))
self.async_set_updated_data(await self._ek_api.post_hop(hop_interval))
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
@@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
try:
async with asyncio.timeout(60):
if self.hop_intervals is None:
hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals()
hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals()
hop_intervals.intervals = OrderedDict(
filter(
lambda pair: pair[1].active == 1,
@@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
)
self.hop_intervals = hop_intervals
return await self.ek_api.get_hop()
return await self._ek_api.get_hop()
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["electrickiwi-api==0.9.14"]
"requirements": ["electrickiwi-api==0.8.5"]
}
@@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity(
"""Initialise the HOP selection entity."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator.ek_api.customer_number}"
f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
f"{coordinator._ek_api.customer_number}" # noqa: SLF001
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001
)
self.entity_description = description
self.values_dict = coordinator.get_hop_options()
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from electrickiwi_api.model import AccountSummary, Hop
from electrickiwi_api.model import AccountBalance, Hop
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -39,15 +39,7 @@ ATTR_HOP_PERCENTAGE = "hop_percentage"
class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription):
"""Describes Electric Kiwi sensor entity."""
value_func: Callable[[AccountSummary], float | datetime]
def _get_hop_percentage(account_balance: AccountSummary) -> float:
"""Return the hop percentage from account summary."""
if power := account_balance.services.get("power"):
if connection := power.connections[0]:
return float(connection.hop_percentage)
return 0.0
value_func: Callable[[AccountBalance], float | datetime]
ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
@@ -80,7 +72,9 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
translation_key="hop_power_savings",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_func=_get_hop_percentage,
value_func=lambda account_balance: float(
account_balance.connections[0].hop_percentage
),
),
)
@@ -171,8 +165,8 @@ class ElectricKiwiAccountEntity(
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator.ek_api.customer_number}"
f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
f"{coordinator._ek_api.customer_number}" # noqa: SLF001
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001
)
self.entity_description = description
@@ -200,8 +194,8 @@ class ElectricKiwiHOPEntity(
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator.ek_api.customer_number}"
f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
f"{coordinator._ek_api.customer_number}" # noqa: SLF001
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001
)
self.entity_description = description
@@ -21,8 +21,7 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -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
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.25.1"],
"requirements": ["pyenphase==1.23.1"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -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)
@@ -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
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]
"requirements": ["pyfireservicerota==0.0.43"]
}
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250214.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%]"
}
},
@@ -7,7 +7,7 @@ from collections.abc import Callable
from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -49,8 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err
_async_notify_backup_listeners_soon(hass)
return True
@@ -58,15 +56,10 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleDriveConfigEntry
) -> bool:
"""Unload a config entry."""
_async_notify_backup_listeners_soon(hass)
hass.loop.call_soon(_notify_backup_listeners, hass)
return True
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
def _notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
@callback
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
hass.loop.call_soon(_async_notify_backup_listeners, hass)
+1 -2
View File
@@ -146,10 +146,9 @@ class DriveClient:
backup.backup_id,
backup_metadata,
)
await self._api.resumable_upload_file(
await self._api.upload_file(
backup_metadata,
open_stream,
backup.size,
timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT),
)
_LOGGER.debug(
@@ -2,10 +2,7 @@
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AUTH_CALLBACK_PATH,
MY_AUTH_CALLBACK_PATH,
)
from homeassistant.helpers import config_entry_oauth2_flow
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
@@ -18,14 +15,9 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
if "my" in hass.config.components:
redirect_url = MY_AUTH_CALLBACK_PATH
else:
ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT"
redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}"
return {
"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": redirect_url,
"redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
}
@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_drive_api"],
"quality_scale": "platinum",
"requirements": ["python-google-drive-api==0.1.0"]
"requirements": ["python-google-drive-api==0.0.2"]
}
@@ -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(
@@ -38,10 +38,6 @@
"local_name": "GV5126*",
"connectable": false
},
{
"local_name": "GV5179*",
"connectable": false
},
{
"local_name": "GVH5127*",
"connectable": false
@@ -135,5 +131,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
"requirements": ["govee-ble==0.43.0"]
"requirements": ["govee-ble==0.42.0"]
}
@@ -11,7 +11,6 @@ from typing import Any
from aiohttp import ClientError
from habiticalib import (
Avatar,
ContentData,
Habitica,
HabiticaException,
@@ -20,6 +19,7 @@ from habiticalib import (
TaskFilter,
TooManyRequestsError,
UserData,
UserStyles,
)
from homeassistant.config_entries import ConfigEntry
@@ -159,10 +159,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
else:
await self.async_request_refresh()
async def generate_avatar(self, avatar: Avatar) -> bytes:
async def generate_avatar(self, user_styles: UserStyles) -> bytes:
"""Generate Avatar."""
png = BytesIO()
await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG")
avatar = BytesIO()
await self.habitica.generate_avatar(
fp=avatar, user_styles=user_styles, fmt="PNG"
)
return png.getvalue()
return avatar.getvalue()
@@ -23,5 +23,5 @@ async def async_get_config_entry_diagnostics(
CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER],
},
"habitica_data": habitica_data.to_dict(omit_none=False)["data"],
"habitica_data": habitica_data.to_dict()["data"],
}
+4 -3
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from dataclasses import asdict
from enum import StrEnum
from habiticalib import Avatar, extract_avatar
from habiticalib import UserStyles
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
@@ -44,7 +45,7 @@ class HabiticaImage(HabiticaBase, ImageEntity):
translation_key=HabiticaImageEntity.AVATAR,
)
_attr_content_type = "image/png"
_current_appearance: Avatar | None = None
_current_appearance: UserStyles | None = None
_cache: bytes | None = None
def __init__(
@@ -59,7 +60,7 @@ class HabiticaImage(HabiticaBase, ImageEntity):
def _handle_coordinator_update(self) -> None:
"""Check if equipped gear and other things have changed since last avatar image generation."""
new_appearance = extract_avatar(self.coordinator.data.user)
new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user))
if self._current_appearance != new_appearance:
self._current_appearance = new_appearance
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.7"]
"requirements": ["habiticalib==0.3.4"]
}
@@ -77,7 +77,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema(
SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_SKILL): cv.string,
vol.Optional(ATTR_TASK): cv.string,
}
@@ -85,12 +85,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
}
)
SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_TASK): cv.string,
vol.Optional(ATTR_DIRECTION): cv.string,
}
@@ -98,7 +98,7 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_ITEM): cv.string,
vol.Required(ATTR_TARGET): cv.string,
}
@@ -106,7 +106,7 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Optional(ATTR_TYPE): vol.All(
cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))]
),
@@ -510,10 +510,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
or (task.notes and keyword in task.notes.lower())
or any(keyword in item.text.lower() for item in task.checklist)
]
result: dict[str, Any] = {
"tasks": [task.to_dict(omit_none=False) for task in response]
}
result: dict[str, Any] = {"tasks": response}
return result
hass.services.async_register(
+22 -31
View File
@@ -20,7 +20,6 @@ from aiohasupervisor.models import (
backups as supervisor_backups,
mounts as supervisor_mounts,
)
from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE
from homeassistant.components.backup import (
DATA_MANAGER,
@@ -28,7 +27,6 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupManagerError,
BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
@@ -57,6 +55,8 @@ from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
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
@@ -71,9 +71,7 @@ async def async_get_backup_agents(
"""Return the hassio backup agents."""
client = get_supervisor_client(hass)
mounts = await client.mounts.info()
agents: list[BackupAgent] = [
SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE)
]
agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)]
for mount in mounts.mounts:
if mount.usage is not supervisor_mounts.MountUsage.BACKUP:
continue
@@ -113,7 +111,7 @@ def async_register_backup_agents_listener(
def _backup_details_to_agent_backup(
details: supervisor_backups.BackupComplete, location: str
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
@@ -126,6 +124,7 @@ def _backup_details_to_agent_backup(
for addon in details.addons
]
extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL
return AgentBackup(
addons=addons,
backup_id=details.slug,
@@ -148,7 +147,7 @@ class SupervisorBackupAgent(BackupAgent):
domain = DOMAIN
def __init__(self, hass: HomeAssistant, name: str, location: str) -> None:
def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
@@ -163,15 +162,10 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
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
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(location=self.location),
)
async def async_upload_backup(
self,
@@ -206,7 +200,7 @@ class SupervisorBackupAgent(BackupAgent):
backup_list = await self._client.backups.list()
result = []
for backup in backup_list:
if self.location not in backup.location_attributes:
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, self.location))
@@ -222,7 +216,7 @@ class SupervisorBackupAgent(BackupAgent):
details = await self._client.backups.backup_info(backup_id)
except SupervisorNotFoundError:
return None
if self.location not in details.location_attributes:
if self.location not in details.locations:
return None
return _backup_details_to_agent_backup(details, self.location)
@@ -295,8 +289,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
# 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] = []
decrypted_locations: list[str] = []
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:
@@ -353,12 +347,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
eager_start=False, # To ensure the task is not started before we return
)
return (NewBackup(backup_job_id=backup.job_id.hex), backup_task)
return (NewBackup(backup_job_id=backup.job_id), backup_task)
async def _async_wait_for_backup(
self,
backup: supervisor_backups.NewBackup,
locations: list[str],
locations: list[str | None],
*,
on_progress: Callable[[CreateBackupEvent], None],
remove_after_upload: bool,
@@ -508,7 +502,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
else None
)
restore_location: str
restore_location: str | None
if manager.backup_agents[agent_id].domain != DOMAIN:
# Download the backup to the supervisor. Supervisor will clean up the backup
# two days after the restore is done.
@@ -534,8 +528,6 @@ 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]
@@ -577,11 +569,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""Check restore status after core restart."""
if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)):
if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)):
_LOGGER.debug("No restore job ID found in environment")
return
restore_job_id = UUID(restore_job_str)
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
sent_event = False
@@ -635,7 +626,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
@callback
def _async_listen_job_events(
self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
) -> Callable[[], None]:
"""Listen for job events."""
@@ -650,7 +641,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
if (
data.get("event") != "job"
or not (event_data := data.get("data"))
or event_data.get("uuid") != job_id.hex
or event_data.get("uuid") != job_id
):
return
on_event(event_data)
@@ -661,10 +652,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
return unsub
async def _get_job_state(
self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
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(job_id)
job = await self._client.jobs.get_job(UUID(job_id))
_LOGGER.debug("Job state: %s", job)
on_event(job.to_dict())
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.3.0"],
"requirements": ["aiohasupervisor==0.2.2b6"],
"single_config_entry": true
}
@@ -0,0 +1 @@
"""Virtual integration: Heicko."""
@@ -0,0 +1,6 @@
{
"domain": "heicko",
"name": "Heicko",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}
+4 -17
View File
@@ -37,24 +37,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
for device in device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
):
for ident in device.identifiers:
if ident[0] != DOMAIN or isinstance(ident[1], str):
continue
player_id = int(ident[1]) # type: ignore[unreachable]
# Create set of identifiers excluding this integration
identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN}
migrated_identifiers = {(DOMAIN, str(player_id))}
# Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded
if not device_registry.async_get_device(migrated_identifiers):
identifiers.update(migrated_identifiers)
if len(identifiers) > 0:
device_registry.async_update_device(
device.id, new_identifiers=identifiers
for domain, player_id in device.identifiers:
if domain == DOMAIN and not isinstance(player_id, str):
device_registry.async_update_device( # type: ignore[unreachable]
device.id, new_identifiers={(DOMAIN, str(player_id))}
)
else:
device_registry.async_remove_device(device.id)
break
coordinator = HeosCoordinator(hass, entry)
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["pyheos"],
"quality_scale": "silver",
"requirements": ["pyheos==1.0.2"],
"requirements": ["pyheos==1.0.1"],
"single_config_entry": true,
"ssdp": [
{
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.66", "babel==2.15.0"]
"requirements": ["holidays==0.65", "babel==2.15.0"]
}
+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."""

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