Compare commits

...

181 Commits

Author SHA1 Message Date
Paulus Schoutsen
00baa90c78 Update homeassistant/components/google_generative_ai_conversation/strings.json 2025-06-21 23:39:12 -04:00
Paulus Schoutsen
3db5e1b551 Add Google LLM Task platform 2025-06-22 03:08:33 +00:00
Paulus Schoutsen
01459d0f35 Add reconfig successful msg 2025-06-22 00:29:07 +00:00
Paulus Schoutsen
818e86f16e Migrate Google Gen AI to use subentries 2025-06-21 23:30:46 +00:00
Simone Chemelli
c453eed32d Bump aioamazondevices to 3.1.14 (#147257) 2025-06-21 15:44:22 +02:00
Joakim Sørensen
79a9f34150 Handle the new JSON payload from traccar clients (#147254) 2025-06-21 11:53:17 +02:00
hanwg
7442f7af28 Fix Telegram bot parsing of inline keyboard (#146376)
* bug fix for inline keyboard

* update inline keyboard test

* Update tests/components/telegram_bot/test_telegram_bot.py

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

* revert last_message_id and updated tests

* removed TypeError test

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-21 03:21:10 +02:00
Markus Adrario
2e5de732a7 Bump pyHomee to version 1.2.10 (#147248)
bump pyHomee to version 1.2.10
2025-06-21 00:32:14 +01:00
Michael Hansen
9bcd74c449 Change async_supports_streaming_input to an instance method (#147245) 2025-06-20 15:39:22 -05:00
Noah Husby
ace18e540b Bump aiorussound to 4.6.1 (#147233) 2025-06-20 21:59:59 +02:00
Michael Hansen
65f897793d Use string instead of boolean for voice event (#147244)
Use string instead of bool
2025-06-20 15:18:03 -04:00
Robert Resch
435c08685d Bump deebot-client to 13.4.0 (#147221) 2025-06-20 20:22:33 +02:00
J. Diego Rodríguez Royo
95f292c43d Bump aiohomeconnect to 0.18.1 (#147236) 2025-06-20 19:27:29 +02:00
Manu
9346c584c3 Add reconfigure flow to ntfy integration (#143743) 2025-06-20 18:42:47 +02:00
Michael Hansen
6738085391 Minor clean up missed in previous PR (#147229) 2025-06-20 10:54:11 -05:00
Markus Adrario
d9e5bad55e Use entity name in homee (#147142)
* add name to HomeeEntity

* review change
2025-06-20 16:55:48 +02:00
Maciej Bieniek
f7429f3431 Fix Shelly entity names for gen1 sleeping devices (#147019) 2025-06-20 15:19:39 +02:00
Petar Petrov
46aea5d9dc Bump zwave-js-server-python to 0.64.0 (#147176) 2025-06-20 14:59:54 +02:00
Paulus Schoutsen
33bde48c9c AI Task integration (#145128)
* Add AI Task integration

* Remove GenTextTaskType

* Add AI Task prefs

* Add action to LLM task

* Remove WS command

* Rename result to text for GenTextTaskResult

* Apply suggestions from code review

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* Add supported feature for generate text

* Update const.py

Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com>

* Update homeassistant/components/ai_task/services.yaml

Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com>

* Use WS API to set preferences

* Simplify pref storage

* Simplify pref test

* Update homeassistant/components/ai_task/services.yaml

Co-authored-by: Allen Porter <allen.porter@gmail.com>

---------

Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com>
2025-06-20 08:56:08 -04:00
Noah Husby
1b73acc025 Add sub-device support to Russound RIO (#146763) 2025-06-20 14:52:34 +02:00
Guido Schmitz
e28965770e Add translations for devolo Home Control exceptions (#147099)
* Add translations for devolo Home Control exceptions

* Adapt invalid_auth message

* Adapt connection_failed message
2025-06-20 14:31:16 +02:00
Kevin Stillhammer
f9d4bde0f6 Bump here-routing to 1.2.0 (#147204)
* Bump here-routing to 1.2.0

* Fix mypy typing errors

* Correct types for call assertion
2025-06-20 13:44:14 +02:00
Duco Sebel
a493bdc208 Implement battery group mode in HomeWizard (#146770)
* Implement battery group mode for HomeWizard P1

* Clean up test

* Disable 'entity_registry_enabled_default'

* Fix failing tests because of 'entity_registry_enabled_default'

* Proof entities are disabled by default

* Undo dev change

* Update homeassistant/components/homewizard/select.py

* Update homeassistant/components/homewizard/select.py

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

* Update homeassistant/components/homewizard/strings.json

* Apply suggestions from code review

* Update tests due to updated translations

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-20 13:19:45 +02:00
Markus Adrario
9ae9ad1e43 Improve test-coverage for homee locks (#147160)
test for unknown user
2025-06-20 12:28:49 +02:00
epenet
1b60ea8951 Migrate lutron to use runtime_data (#147198) 2025-06-20 12:26:07 +02:00
epenet
313eaff14e Migrate kaleidescape to use runtime_data (#147171)
* Migrate kaleidescape to use runtime_data

* Adjust tests
2025-06-20 12:25:57 +02:00
epenet
7dfd68f8c0 Migrate keenetic_ndms2 to use runtime_data (#147194)
* Migrate keenetic_ndms2 to use runtime_data

* Adjust tests
2025-06-20 12:23:59 +02:00
epenet
544fd2a4a6 Migrate lacrosse_view to use runtime_data (#147202) 2025-06-20 12:23:29 +02:00
epenet
cd51070219 Migrate kmtronic to use runtime_data (#147193) 2025-06-20 11:39:13 +02:00
Marc Mueller
3c91c78383 Use PEP 695 TypeVar syntax for ecovacs (#147153) 2025-06-20 10:41:25 +02:00
Brett Adams
96e0d1f5c6 Fix Charge Cable binary sensor in Teslemetry (#147136) 2025-06-20 10:39:43 +02:00
epenet
2859e7de9b Migrate kodi to use runtime_data (#147191) 2025-06-20 10:38:01 +02:00
Robert Resch
88683a318d Add support of taking a camera snapshot via go2rtc (#145205) 2025-06-20 10:34:43 +02:00
epenet
84e9422254 Move juicenet coordinator to separate module (#147168) 2025-06-20 10:33:17 +02:00
epenet
fde36d5034 Simplify update_listener in konnected (#147172) 2025-06-20 10:31:28 +02:00
Andre Lengwenus
8c1e43c07c Bump pypck to 0.8.9 (#147174) 2025-06-20 10:28:35 +02:00
epenet
05343392a7 Simplify update_listener in keenetic_ndms2 (#147173) 2025-06-20 10:27:47 +02:00
epenet
32314dbb13 Simplify update_listener in kmtronic (#147184) 2025-06-20 10:27:07 +02:00
epenet
8f661fc5cf Migrate kegtron to use runtime_data (#147177) 2025-06-20 10:26:53 +02:00
epenet
e315cb9859 Migrate kostal_plenticore to use runtime_data (#147188) 2025-06-20 10:25:08 +02:00
epenet
d0e77eb1e2 Migrate keymitt_ble to use runtime_data (#147179) 2025-06-20 10:24:56 +02:00
epenet
e23cac8bef Simplify remove listener in kodi (#147183) 2025-06-20 10:23:41 +02:00
epenet
973700542b Move kmtronic coordinator to separate module (#147182) 2025-06-20 10:19:19 +02:00
Krisjanis Lejejs
2e21493c19 Bump hass-nabucasa from 0.102.0 to 0.103.0 (#147186) 2025-06-20 10:18:03 +02:00
Markus Adrario
73bed96a0f remove unwanted attribute in homee sensor tests (#147158) 2025-06-20 08:11:20 +02:00
Markus Adrario
0a5d13f104 fix and improve cover tests for homee (#147164) 2025-06-20 08:10:44 +02:00
epenet
d16ec81727 Migrate justnimbus to use runtime_data (#147170) 2025-06-20 08:10:06 +02:00
Martin Hjelmare
11564e3df5 Fix Z-Wave device class endpoint discovery (#142171)
* Add test fixture and test for Glass 9 shutter

* Fix zwave_js device class discovery matcher

* Fall back to node device class

* Fix test_special_meters modifying node state

* Handle value added after node ready
2025-06-20 08:56:20 +03:00
Michael Hansen
341d9f15f0 Add ask_question action to Assist satellite (#145233)
* Add get_response to Assist satellite and ESPHome

* Rename get_response to ask_question

* Add possible answers to questions

* Add wildcard support and entity test

* Add ESPHome test

* Refactor to remove async_ask_question

* Use single entity_id instead of target

* Fix error message

* Remove ESPHome test

* Clean up

* Revert fix
2025-06-19 16:50:14 -05:00
Marc Mueller
2c13c70e12 Update ruff to 0.12.0 (#147106) 2025-06-19 20:39:09 +02:00
Marc Mueller
73d0d87705 Use PEP 695 TypeVar syntax for nextdns (#147155) 2025-06-19 20:26:07 +02:00
Marc Mueller
b8dfb2c850 Use PEP 695 TypeVar syntax for eheimdigital (#147154) 2025-06-19 20:25:45 +02:00
Marc Mueller
cf67a68454 Use PEP 695 TypeVar syntax for paperless_ngx (#147156) 2025-06-19 20:24:51 +02:00
karwosts
b003429912 Expose statistics selector, use for recorder.get_statistics (#147056)
* Expose statistics selector, use for `recorder.get_statistics`

* code review

* syntax formatting

* rerun ci
2025-06-19 20:04:28 +02:00
hahn-th
4aff032442 Bump homematicip to 2.0.6 (#147151) 2025-06-19 18:55:14 +02:00
Martin Hjelmare
da3d8a6332 Improve advanced Z-Wave battery discovery (#147127) 2025-06-19 18:56:47 +03:00
Marc Mueller
7a5c088149 [ci] Bump cache key version (#147148) 2025-06-19 17:42:30 +02:00
Norbert Rittel
31eec6f471 Add missing hyphen to "mains-powered" and "battery-powered" in zha (#147128)
Add missing hyphen to "mains-powered" and "battery-powered"
2025-06-19 14:36:40 +03:00
G Johansson
c602a0e279 Deprecated hass.http.register_static_path now raises error (#147039) 2025-06-19 13:14:42 +02:00
Marc Mueller
513045e489 Update pytest warnings filter (#147132) 2025-06-19 13:07:42 +02:00
Erik Montnemery
0db6520802 Add comment in helpers.llm.ActionTool explaining limitations (#147116) 2025-06-19 12:59:35 +02:00
Erik Montnemery
5bc2e271d2 Re-raise annotated_yaml.YAMLException as HomeAssistantError (#147129)
* Re-raise annotated_yaml.YAMLException as HomeAssistantError

* Fix comment
2025-06-19 12:52:01 +02:00
G Johansson
77dca49c75 Fix pylint plugin for vacuum entity (#146467)
* Clean out legacy VacuumEntity from pylint plugins

* Fix

* Fix pylint for vacuum

* More fixes

* Revert partial

* Add back state
2025-06-19 12:49:10 +02:00
Franck Nijhof
1baba8b880 Adjust feature request links in issue reporting (#147130) 2025-06-19 12:36:43 +02:00
Markus Adrario
875d81cab2 update pyHomee to v1.2.9 (#147094) 2025-06-19 12:04:59 +02:00
Raphael Hehl
956f726ef3 Bump uiprotect to version 7.14.0 (#147102) 2025-06-19 11:20:29 +02:00
epenet
fada81e1ce Bump ovoenergy to 2.0.1 (#147112) 2025-06-19 08:46:03 +02:00
Simon Lamon
6a16424bb4 Fix nightly build (#147110)
Update builder.yml
2025-06-19 08:20:19 +02:00
Abílio Costa
f90a740429 Use non-autospec mock for Reolink's binary_sensor, camera and diag tests (#147095) 2025-06-19 08:03:48 +02:00
Michael Hansen
3dba7e5bd2 Send intent progress events to ESPHome (#146966) 2025-06-18 22:12:37 -04:00
Erik Montnemery
8d8ff011fc Minor improvements of service helper (#147079) 2025-06-19 00:17:12 +01:00
Michael Hansen
6befd065a1 Bump aioesphomeapi to 32.2.4 (#147100)
Bump aioesphomeapi
2025-06-18 15:49:44 -05:00
Abílio Costa
9adf493acd Use non-autospec mock for Reolink's init tests (#146991) 2025-06-18 17:58:50 +01:00
Michael Hansen
a29d5fb56c tts_output is optional in run-start (#147092) 2025-06-18 12:08:53 -04:00
Petro31
bcb87cf812 Support variables, icon, and picture for all compatible template platforms (#145893)
* Fix template entity variables in blueprints

* add picture and icon tests

* add variable test for all platforms

* apply comments

* Update all test names
2025-06-18 16:49:46 +02:00
Jan Bouwhuis
d01758cea8 Ensure mqtt sensor has a valid native unit of measurement (#146722) 2025-06-18 15:48:38 +02:00
Joakim Sørensen
5487bfe1d9 Bump hass-nabucasa from 0.101.0 to 0.102.0 (#147087) 2025-06-18 15:47:01 +02:00
Simone Chemelli
fec65f40fc Bump aioamazondevices to 3.1.12 (#147055)
* Bump aioamazondevices to 3.1.10

* bump to 3.1.12
2025-06-18 10:20:51 +02:00
Guido Schmitz
596951ea9f Cleanup devolo Home Control tests (#147051) 2025-06-18 09:24:09 +02:00
Norbert Rittel
75d6b885cf Fix typo in state name references of homee (#146905)
Fix typo in state references

Replace wrong semicolons with colon.
2025-06-18 09:23:37 +02:00
Guido Schmitz
3fad76dfa1 Use missed typed ConfigEntry in devolo Home Control (#147049) 2025-06-18 09:22:37 +02:00
Pete Sage
43d8a151ab Remove internals from Sonos test_init.py (#147063)
* fix: test init

* fix: revert

* fix: revert

* fix: revert

* fix: revert

* fix: simplify
2025-06-18 09:21:21 +02:00
starkillerOG
07110e288d If no Reolink HTTP api available, do not set configuration_url (#146684)
* If no http api available, do not set configuration_url

* Add tests
2025-06-18 09:16:08 +02:00
Jan-Philipp Benecke
ba2aac4614 Bump aiowebdav2 to 0.4.6 (#147054) 2025-06-18 09:15:27 +02:00
msw
3449dae7a2 Capitalize "Ice Bites" and switch to "Cubed ice" (#147060) (#147061) 2025-06-18 09:14:45 +02:00
G Johansson
b8cd3f3635 Bump holidays lib to 0.75 (#147043) 2025-06-18 10:11:01 +03:00
Martin Hjelmare
be53ad5449 Disable Z-Wave idle notification button (#147026)
* Update test

* Disable Z-Wave idle notification button

* Update tests
2025-06-18 08:29:04 +03:00
J. Diego Rodríguez Royo
ffd940e07c Set quality scale at Home Connect manifest (#147050) 2025-06-17 21:42:40 +01:00
Josef Zweck
5e31b5ac4f Handle missing widget in lamarzocco (#147047) 2025-06-17 21:25:27 +02:00
puddly
81257f9d57 Bump ZHA to 0.0.60 (#147045) 2025-06-17 22:06:53 +03:00
Josef Zweck
ce1678719a Bump pylamarzocco to 2.0.9 (#147046) 2025-06-17 20:59:41 +02:00
Guido Schmitz
fc6844b3c9 Add _attr_has_entity_name to devolo Home Network device tracker platform (#146978)
* Add _attr_has_entity_name to devolo Home Network device tracker platform

* Set name

* Fix tests
2025-06-17 20:49:52 +02:00
J. Diego Rodríguez Royo
8e82e3aa3a Bump aiohomeconnect to 0.18.0 (#147044) 2025-06-17 20:48:09 +02:00
G Johansson
3bc68941e6 Remove not used constant in climate (#147041) 2025-06-17 20:43:16 +02:00
Josef Zweck
e69b38ab2c Fix log in onedrive (#147029) 2025-06-17 19:57:52 +02:00
Abílio Costa
ed9503324d Fix flaky Reolink webhook test (#147036) 2025-06-17 17:18:48 +01:00
Allen Porter
22a06a6c2e Bump ical to 10.0.4 (#147005)
* Bump ical to 10.0.4

* Bump ical to 10.0.4 in google
2025-06-17 07:06:51 -07:00
Michael Hansen
3b611b9b03 Add TTS response timeout for idle state (#146984)
* Add TTS response timeout for idle state

* Consider time spent sending TTS audio in timeout
2025-06-17 09:39:18 -04:00
Noah Husby
79cc3bffc6 Bump aiorussound to 4.6.0 (#147023) 2025-06-17 14:40:56 +02:00
Martin Hjelmare
5c455304a5 Disable Z-Wave indidator CC entities by default (#147018)
* Update discovery tests

* Disable Z-Wave indidator CC entities by default
2025-06-17 15:39:22 +03:00
Erik Montnemery
058f860be7 Fix incorrect use of zip in service.async_get_all_descriptions (#147013)
* Fix incorrect use of zip in service.async_get_all_descriptions

* Fix lint errors in test
2025-06-17 14:24:31 +02:00
Joost Lekkerkerker
ef319c966d Bump nextcord to 3.1.0 (#147020) 2025-06-17 14:11:55 +02:00
Robin Lintermann
adc4e9fdc1 Bump pysmarlaapi version to 0.9.0 (#146629)
Bump pysmarlaapi version
Fix default values of entities
2025-06-17 11:23:50 +02:00
Maciej Bieniek
40a00fb790 Address late review for NextDNS integration (#146980)
key instead of Key
2025-06-17 11:23:03 +02:00
G Johansson
0926b16095 Remove deprecated support feature values in cover (#146987) 2025-06-17 10:46:08 +02:00
G Johansson
308c89af4a Remove deprecated support feature values in media_player (#146986) 2025-06-17 10:33:41 +02:00
G Johansson
b0c2a47288 Remove deprecated support feature values in vacuum (#146982) 2025-06-17 10:32:58 +02:00
Joost Lekkerkerker
c446cce2cc Bump pySmartThings to 3.2.5 (#146983) 2025-06-16 22:44:14 +01:00
Abílio Costa
e02267ad89 Improve bootstrap file logging test (#146670) 2025-06-16 21:55:16 +01:00
Thomas55555
36381e6753 Bump aioautomower to 2025.6.0 (#146979) 2025-06-16 22:52:23 +02:00
Manu
6533562f4e Rename Xiaomi Miio integration to Xiaomi Home (#146555)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-16 21:51:54 +01:00
Ludovic BOUÉ
1bc6ea98ce Set Matter SolarPower tagList in fixture (#146837)
Update solar_power.json

Set tagList to [{"0":null,"1":15,"2":2,"3":"Solar"}]
2025-06-16 22:46:27 +02:00
elmurato
bab34b844b Fix blocking open in Minecraft Server (#146820)
Fix blocking open by dnspython
2025-06-16 22:46:11 +02:00
Etienne C.
ad3dac0373 Removed rounding of durations in Here Travel Time sensors (#146838)
* Removed rounding of durations

* Set duration sensors unit to seconds

* Updated Here Travel Time tests

* Update homeassistant/components/here_travel_time/sensor.py

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

* Update homeassistant/components/here_travel_time/sensor.py

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

* Updated Here Travel Time tests

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-16 22:20:01 +02:00
Maciej Bieniek
c5d93e5456 Fix translation key in NextDNS integration (#146976)
* Fix translation key

* Better wording
2025-06-16 21:37:19 +02:00
J. Diego Rodríguez Royo
ef9b46dce5 Record current IQS state for Home Connect (#131703)
* Home Connect quality scale

* Update current iqs

* Docs rules done

* parallel-updates rule

* Complete appropriate-polling's comment

* Apply suggestions

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

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-16 21:30:06 +02:00
Abílio Costa
6f3ceb83c2 Use non-autospec mock for Reolink's button tests (#146969) 2025-06-16 21:14:02 +02:00
Joost Lekkerkerker
589577a04c Add diagnostics support to Meater (#146967) 2025-06-16 20:17:30 +02:00
Joost Lekkerkerker
cb21bb6542 Make Meater cook state an enum (#146958) 2025-06-16 19:13:34 +01:00
mswilson
ad64139b8e Add switch for Samsung ice bites (and rename ice maker) (#146925)
* Add switch for ice bites (and rename ice maker)

Fixes: home-assistant/home-assistant.io#37826

* Fix tests

* Fix

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2025-06-16 19:31:49 +02:00
Joost Lekkerkerker
9ae0cfc7e5 Create entities directly on setup in Meater (#146953)
* Don't wait an update when adding devices in Meater

* Fix
2025-06-16 18:23:20 +02:00
Joost Lekkerkerker
dffaf49eca Use runtime data in Meater (#146961) 2025-06-16 17:18:21 +02:00
Maciej Bieniek
4add783108 Use entity base class for NextDNS entities (#146934)
* Add entity module

* Add NextDnsEntityDescription class

* Remove NextDnsEntityDescription

* Create DeviceInfo in entity module

* Use property
2025-06-16 16:58:47 +02:00
Joost Lekkerkerker
421251308f Add Meater sensor tests (#146952) 2025-06-16 16:19:35 +02:00
Aviad Levy
cce878213f Add Telegram Bot message reactions (#146354) 2025-06-16 14:48:59 +01:00
Joost Lekkerkerker
664441eaec Improve Meater config flow tests (#146951) 2025-06-16 15:40:43 +02:00
Maciej Bieniek
d4686a3cce Add config flow data description for NextDNS (#146938)
* Add config flow data description

* Better wording
2025-06-16 15:28:25 +02:00
Hessel
6e92247799 Fix missing key for ecosmart in older Wallbox models (#146847)
* fix 146839, missing key

* added tests for this issue

* added tests for this issue

* added tests for this issue, formatting

* Prevent loading select on missing key

* Prevent loading select on missing key - formatting fixed

* Update homeassistant/components/wallbox/coordinator.py

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

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-16 15:15:17 +02:00
Etienne C.
f5355c833e Add duration device class in Here Travel Time sensors (#146804) 2025-06-16 15:14:43 +02:00
Joost Lekkerkerker
add9f4c5ab Move Meater coordinator to module (#146946)
* Move Meater coordinator to module

* Fix tests
2025-06-16 14:48:44 +02:00
starkillerOG
38973fe64a Add Reolink privacy mask switch (#146906) 2025-06-16 14:40:19 +02:00
epenet
d657964729 Simplify habitica service actions (#146746) 2025-06-16 14:37:38 +02:00
Nathan Spencer
25c408484c Set goalzero total run time sensor device class to duration (#146897) 2025-06-16 14:35:56 +02:00
Florian von Garrel
c335b5b37c Add verify ssl option to paperless-ngx integration (#146802)
* add verify ssl config option

* Refactoring

* Use .get() with default value instead of migration

* Reconfigure fix

* minor changes
2025-06-16 14:31:22 +02:00
Josef Zweck
61b00892c3 Add debug log for update in onedrive (#146907) 2025-06-16 14:17:36 +02:00
Maciej Bieniek
e47e2c92fe Change PARALLEL_UPDATES to 0 for read-only NextDNS platforms (#146939)
Change PARALLEL_UPDATES to 0 for read-only platforms
2025-06-16 14:11:48 +02:00
Duco Sebel
3283965b45 Re-enable v2 API support for HomeWizard P1 Meter (#146927) 2025-06-16 14:11:35 +02:00
epenet
4a9cbc79f2 Bump pysml to 0.1.5 (#146935) 2025-06-16 12:56:03 +01:00
epenet
33978ce59e Bump pyosoenergyapi to 1.1.5 (#146942) 2025-06-16 12:46:38 +01:00
epenet
d5262231a1 Bump pymysensors to 0.25.0 (#146941) 2025-06-16 13:37:39 +02:00
Brett Adams
b563f9078a Significantly improve Tesla Fleet config flow (#146794)
* Improved config flow

* Tests

* Improvements

* Dashboard url & tests

* Apply suggestions from code review

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* revert oauth change

* fully restore oauth file

* remove CONF_DOMAIN

* Add pick_implementation back in

* Use try else

* Improve translation

* use CONF_DOMAIN

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-16 13:29:17 +02:00
epenet
e8667dfbe0 Bump nessclient to 1.2.0 (#146937) 2025-06-16 12:11:57 +01:00
dependabot[bot]
8d4f5d78ff Bump dawidd6/action-download-artifact from 10 to 11 (#146928)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 10 to 11.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/v10...v11)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:42:10 +02:00
mbo18
e354a850c9 Bump python-rflink to 0.0.67 (#146908)
* update python-rflink

* remove from FORBIDDEN_PACKAGE_EXCEPTIONS
2025-06-16 10:36:20 +02:00
Ernst Klamer
5ea026d369 Bump bthome-ble to 3.13.1 (#146871) 2025-06-16 11:29:00 +03:00
Brett Adams
ddfe17d0a4 Bump tesla-fleet-api to match Protobuf compatibility (#146918)
Bump for v1.2.0
2025-06-16 10:12:34 +02:00
Yuxin Wang
85aa7bef1e Add sensor categorizations for APCUPSD (#146863)
* Add sensor categorizations

* Fix snapshot problem

* Fix snapshot problem
2025-06-16 08:43:31 +02:00
Paulus Schoutsen
8498928e47 Move Google Gen AI fixture to allow reuse (#146921) 2025-06-15 23:00:27 -04:00
Paulus Schoutsen
fa21269f0d Simplify ChatLog dependencies (#146351) 2025-06-15 17:41:15 -04:00
starkillerOG
5f5869ffc6 Bump reolink-aio to 0.14.1 (#146903) 2025-06-15 20:53:32 +02:00
Nathan Spencer
7a2d99a450 Bump pylitterbot to 2024.2.0 (#146901) 2025-06-15 20:41:07 +02:00
Andre Lengwenus
6b669ce40c Bump pypck to 0.8.8 (#146841) 2025-06-15 19:32:13 +02:00
Markus Adrario
fdf4ed2aa5 Homee add button_state to event entities (#146860)
* use entityDescription

* Add new event and adapt tests

* change translation

* use references in strings
2025-06-15 18:17:52 +02:00
Simone Chemelli
1361d10cd7 Bump aioamazondevices to 3.1.4 (#146883) 2025-06-15 08:30:19 -07:00
Marc Mueller
8c7ba11493 Fix telegram_bot RuntimeWarning in tests (#146781) 2025-06-15 11:23:17 +03:00
Marc Mueller
29ce17abf4 Update eq3btsmart to 2.1.0 (#146335)
* Update eq3btsmart to 2.1.0

* Update import names

* Update register callbacks

* Updated data model

* Update Thermostat set value methods

* Update Thermostat init

* Thermostat status and device_data are always given

* Minor compatibility fixes

---------

Co-authored-by: Lennard Beers <l.beers@outlook.de>
2025-06-15 10:17:01 +02:00
Markus Lanthaler
c988d1ce36 Add support for Gemini's new TTS capabilities (#145872)
* Add support for Gemini TTS

* Add tests

* Use wave library and update a few comments
2025-06-14 22:21:04 -07:00
Paulus Schoutsen
ec02f6d010 Extract Google LLM base entity class (#146817) 2025-06-14 22:17:52 -07:00
Simone Chemelli
9f19c4250a Bump aioamazondevices to 3.1.3 (#146828) 2025-06-15 01:45:28 +03:00
Marc Mueller
d7b583ae51 Update pydantic to 2.11.7 (#146835) 2025-06-14 23:31:09 +02:00
Maciej Bieniek
152e5254e2 Use Shelly main device area as suggested area for sub-devices (#146810) 2025-06-14 13:53:51 -04:00
starkillerOG
3f8f7cd578 Bump motion blinds to 0.6.28 (#146831) 2025-06-14 19:01:41 +02:00
Chris Talkington
ed3fb62ffc Update rokuecp to 0.19.5 (#146788) 2025-06-14 18:49:16 +02:00
J. Nick Koston
1d14e1f018 Bump aiohttp to 3.12.13 (#146830)
changelog: https://github.com/aio-libs/aiohttp/compare/v3.12.12...v3.12.13

Likely does not affect us at all but just in case, tagging
2025-06-14 17:13:20 +01:00
hahn-th
2ac8901a0d Improve code quality in async_setup_entry of switches in homematicip_cloud (#146816)
improve setup of switches
2025-06-14 17:26:08 +02:00
Joris Pelgröm
6204fd5363 Add polling to LetPot coordinator (#146823)
- Adds polling (update_interval) to the coordinator for the LetPot integration. Push remains the primary update mechanism for all entities, but:
   - Polling makes entities go unavailable when the device can't be reached, which otherwise won't happen.
   - Pump changes do not always trigger a status push by the device (not sure why), polling makes the integration catch up to reality.
2025-06-14 16:24:48 +02:00
Brett Adams
ce52ef64db Bump tesla-fleet-api to 1.1.3 (#146793) 2025-06-14 08:39:27 -05:00
Paulus Schoutsen
059c12798d Drop user prompt from LLMContext (#146787) 2025-06-13 22:01:39 -04:00
epenet
56aa809074 Simplify google_photos service actions (#146744) 2025-06-13 18:57:11 -07:00
Marc Mueller
3d2dca5f0c Adjust scripts for compatibility with Python 3.14 (#146774) 2025-06-13 21:54:25 -04:00
starkillerOG
cdb2b407be Add Reolink baby cry sensitivity (#146773)
* Add baby cry sensitivity

* Adjust tests
2025-06-14 00:11:13 +01:00
Ian
186ed451a9 Bump nextbus client to 2.3.0 (#146780) 2025-06-14 00:09:29 +01:00
hahn-th
761a0877e6 Fix throttling issue in HomematicIP Cloud (#146683)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-06-13 19:57:03 +02:00
J. Nick Koston
91bc56b15c Bump aiodns to 3.5.0 (#146758) 2025-06-13 19:12:52 +02:00
Paulus Schoutsen
d1e2c62433 Remove unnecessary string formatting. (#146762) 2025-06-13 10:10:47 -07:00
Duco Sebel
524c16fbe1 Bumb python-homewizard-energy to 9.1.1 (#146723)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-06-13 18:59:28 +02:00
Marc Mueller
2fdd3d66bc Update pydantic to 2.11.6 (#146745) 2025-06-13 18:53:05 +02:00
Simone Chemelli
6a1e3b60ee Filter speak notify entity for WHA devices in Alexa Devices (#146688) 2025-06-13 18:49:18 +02:00
DeerMaximum
434cd95a66 Use ConfigEntry.runtime_data to store runtime data in NINA (#146754)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-13 18:47:21 +02:00
Vasilis Valatsos
1a5bc2c7e0 Drop HostKeyAlgorithms in aruba (#146619) 2025-06-13 18:47:07 +02:00
epenet
a66e9a1a2c Simplify reolink service actions (#146751) 2025-06-13 18:08:59 +02:00
536 changed files with 28015 additions and 5175 deletions

View File

@@ -1,15 +1,14 @@
name: Report an issue with Home Assistant Core
description: Report an issue with Home Assistant Core.
type: Bug
body:
- type: markdown
attributes:
value: |
This issue form is for reporting bugs only!
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
If you have a feature or enhancement request, please [request them here instead][fr].
[fr]: https://community.home-assistant.io/c/feature-requests
[fr]: https://github.com/orgs/home-assistant/discussions
- type: textarea
validations:
required: true

View File

@@ -10,8 +10,8 @@ contact_links:
url: https://www.home-assistant.io/help
about: We use GitHub for tracking bugs, check our website for resources on getting help.
- name: Feature Request
url: https://community.home-assistant.io/c/feature-requests
about: Please use our Community Forum for making feature requests.
url: https://github.com/orgs/home-assistant/discussions
about: Please use this link to request new features or enhancements to existing features.
- name: I'm unsure where to go
url: https://www.home-assistant.io/join-chat
about: If you are unsure where to go, then joining our chat is recommended; Just ask!

View File

@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v10
uses: dawidd6/action-download-artifact@v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,10 +105,10 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v10
uses: dawidd6/action-download-artifact@v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
repo: OHF-Voice/intents-package
branch: main
workflow: nightly.yaml
workflow_conclusion: success

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 2
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.7"

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
rev: v0.12.0
hooks:
- id: ruff-check
args:

2
CODEOWNERS generated
View File

@@ -57,6 +57,8 @@ build.json @home-assistant/supervisor
/tests/components/aemet/ @Noltari
/homeassistant/components/agent_dvr/ @ispysoftware
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/ai_task/ @home-assistant/core
/tests/components/ai_task/ @home-assistant/core
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek

View File

@@ -38,8 +38,7 @@ def validate_python() -> None:
def ensure_config_path(config_dir: str) -> None:
"""Validate the configuration directory."""
# pylint: disable-next=import-outside-toplevel
from . import config as config_util
from . import config as config_util # noqa: PLC0415
lib_dir = os.path.join(config_dir, "deps")
@@ -80,8 +79,7 @@ def ensure_config_path(config_dir: str) -> None:
def get_arguments() -> argparse.Namespace:
"""Get parsed passed in arguments."""
# pylint: disable-next=import-outside-toplevel
from . import config as config_util
from . import config as config_util # noqa: PLC0415
parser = argparse.ArgumentParser(
description="Home Assistant: Observe, Control, Automate.",
@@ -177,8 +175,7 @@ def main() -> int:
validate_os()
if args.script is not None:
# pylint: disable-next=import-outside-toplevel
from . import scripts
from . import scripts # noqa: PLC0415
return scripts.run(args.script)
@@ -188,8 +185,7 @@ def main() -> int:
ensure_config_path(config_dir)
# pylint: disable-next=import-outside-toplevel
from . import config, runner
from . import config, runner # noqa: PLC0415
safe_mode = config.safe_mode_enabled(config_dir)

View File

@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
def _generate_secret() -> str:
"""Generate a secret."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
return str(pyotp.random_base32())
def _generate_random() -> int:
"""Generate a 32 digit number."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
def _generate_otp(secret: str, count: int) -> str:
"""Generate one time password."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
return str(pyotp.HOTP(secret).at(count))
def _verify_otp(secret: str, otp: str, count: int) -> bool:
"""Verify one time password."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
return bool(pyotp.HOTP(secret).verify(otp, count))

View File

@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
def _generate_qr_code(data: str) -> str:
"""Generate a base64 PNG string represent QR Code image of data."""
import pyqrcode # pylint: disable=import-outside-toplevel
import pyqrcode # noqa: PLC0415
qr_code = pyqrcode.create(data)
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
"""Generate a secret, url, and QR code."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
ota_secret = pyotp.random_base32()
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
@@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
"""Create a ota_secret for user."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
ota_secret: str = secret or pyotp.random_base32()
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
def _validate_2fa(self, user_id: str, code: str) -> bool:
"""Validate two factor authentication code."""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
# even we cannot find user, we still do verify
@@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
Return self.async_show_form(step_id='init') if user_input is None.
Return self.async_create_entry(data={'result': result}) if finish.
"""
import pyotp # pylint: disable=import-outside-toplevel
import pyotp # noqa: PLC0415
errors: dict[str, str] = {}

View File

@@ -394,7 +394,7 @@ async def async_setup_hass(
def open_hass_ui(hass: core.HomeAssistant) -> None:
"""Open the UI."""
import webbrowser # pylint: disable=import-outside-toplevel
import webbrowser # noqa: PLC0415
if hass.config.api is None or "frontend" not in hass.config.components:
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
@@ -561,8 +561,7 @@ async def async_enable_logging(
if not log_no_color:
try:
# pylint: disable-next=import-outside-toplevel
from colorlog import ColoredFormatter
from colorlog import ColoredFormatter # noqa: PLC0415
# basicConfig must be called after importing colorlog in order to
# ensure that the handlers it sets up wraps the correct streams.
@@ -606,7 +605,7 @@ async def async_enable_logging(
)
threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception",
exc_info=( # type: ignore[arg-type]
exc_info=( # type: ignore[arg-type] # noqa: LOG014
args.exc_type,
args.exc_value,
args.exc_traceback,
@@ -1060,5 +1059,5 @@ async def _async_setup_multi_components(
_LOGGER.error(
"Error setting up integration %s - received exception",
domain,
exc_info=(type(result), result, result.__traceback__),
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
)

View File

@@ -0,0 +1,125 @@
"""Integration to offer AI tasks to Home Assistant."""
import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import (
HassJobType,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import config_validation as cv, storage
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature
from .entity import AITaskEntity
from .http import async_setup as async_setup_conversation_http
from .task import GenTextTask, GenTextTaskResult, async_generate_text
__all__ = [
"DOMAIN",
"AITaskEntity",
"AITaskEntityFeature",
"GenTextTask",
"GenTextTaskResult",
"async_generate_text",
"async_setup",
"async_setup_entry",
"async_unload_entry",
]
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service."""
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
await hass.data[DATA_PREFERENCES].async_load()
async_setup_conversation_http(hass)
hass.services.async_register(
DOMAIN,
"generate_text",
async_service_generate_text,
schema=vol.Schema(
{
vol.Required("task_name"): cv.string,
vol.Optional("entity_id"): cv.entity_id,
vol.Required("instructions"): cv.string,
}
),
supports_response=SupportsResponse.ONLY,
job_type=HassJobType.Coroutinefunction,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
async def async_service_generate_text(call: ServiceCall) -> ServiceResponse:
"""Run the run task service."""
result = await async_generate_text(hass=call.hass, **call.data)
return result.as_dict() # type: ignore[return-value]
class AITaskPreferences:
"""AI Task preferences."""
KEYS = ("gen_text_entity_id",)
gen_text_entity_id: str | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the preferences."""
self._store: storage.Store[dict[str, str | None]] = storage.Store(
hass, 1, DOMAIN
)
async def async_load(self) -> None:
"""Load the data from the store."""
data = await self._store.async_load()
if data is None:
return
for key in self.KEYS:
setattr(self, key, data[key])
@callback
def async_set_preferences(
self,
*,
gen_text_entity_id: str | None | UndefinedType = UNDEFINED,
) -> None:
"""Set the preferences."""
changed = False
for key, value in (("gen_text_entity_id", gen_text_entity_id),):
if value is not UNDEFINED:
if getattr(self, key) != value:
setattr(self, key, value)
changed = True
if not changed:
return
self._store.async_delay_save(self.as_dict, 10)
@callback
def as_dict(self) -> dict[str, str | None]:
"""Get the current preferences."""
return {key: getattr(self, key) for key in self.KEYS}

View File

@@ -0,0 +1,29 @@
"""Constants for the AI Task integration."""
from __future__ import annotations
from enum import IntFlag
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent
from . import AITaskPreferences
from .entity import AITaskEntity
DOMAIN = "ai_task"
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
DEFAULT_SYSTEM_PROMPT = (
"You are a Home Assistant expert and help users with their tasks."
)
class AITaskEntityFeature(IntFlag):
"""Supported features of the AI task entity."""
GENERATE_TEXT = 1
"""Generate text based on instructions."""

View File

@@ -0,0 +1,103 @@
"""Entity for the AI Task integration."""
from collections.abc import AsyncGenerator
import contextlib
from typing import final
from propcache.api import cached_property
from homeassistant.components.conversation import (
ChatLog,
UserContent,
async_get_chat_log,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import llm
from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
from .task import GenTextTask, GenTextTaskResult
class AITaskEntity(RestoreEntity):
"""Entity that supports conversations."""
_attr_should_poll = False
_attr_supported_features = AITaskEntityFeature(0)
__last_activity: str | None = None
@property
@final
def state(self) -> str | None:
"""Return the state of the entity."""
if self.__last_activity is None:
return None
return self.__last_activity
@cached_property
def supported_features(self) -> AITaskEntityFeature:
"""Flag supported features."""
return self._attr_supported_features
async def async_internal_added_to_hass(self) -> None:
"""Call when the entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if (
state is not None
and state.state is not None
and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
):
self.__last_activity = state.state
@final
@contextlib.asynccontextmanager
async def _async_get_ai_task_chat_log(
self,
task: GenTextTask,
) -> AsyncGenerator[ChatLog]:
"""Context manager used to manage the ChatLog used during an AI Task."""
# pylint: disable-next=contextmanager-generator-missing-cleanup
with (
async_get_chat_session(self.hass) as session,
async_get_chat_log(
self.hass,
session,
None,
) as chat_log,
):
await chat_log.async_provide_llm_data(
llm.LLMContext(
platform=self.platform.domain,
context=None,
language=None,
assistant=DOMAIN,
device_id=None,
),
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
)
chat_log.async_add_user_content(UserContent(task.instructions))
yield chat_log
@final
async def internal_async_generate_text(
self,
task: GenTextTask,
) -> GenTextTaskResult:
"""Run a gen text task."""
self.__last_activity = dt_util.utcnow().isoformat()
self.async_write_ha_state()
async with self._async_get_ai_task_chat_log(task) as chat_log:
return await self._async_generate_text(task, chat_log)
async def _async_generate_text(
self,
task: GenTextTask,
chat_log: ChatLog,
) -> GenTextTaskResult:
"""Handle a gen text task."""
raise NotImplementedError

View File

@@ -0,0 +1,54 @@
"""HTTP endpoint for AI Task integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import DATA_PREFERENCES
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the HTTP API for the conversation integration."""
websocket_api.async_register_command(hass, websocket_get_preferences)
websocket_api.async_register_command(hass, websocket_set_preferences)
@websocket_api.websocket_command(
{
vol.Required("type"): "ai_task/preferences/get",
}
)
@callback
def websocket_get_preferences(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get AI task preferences."""
preferences = hass.data[DATA_PREFERENCES]
connection.send_result(msg["id"], preferences.as_dict())
@websocket_api.websocket_command(
{
vol.Required("type"): "ai_task/preferences/set",
vol.Optional("gen_text_entity_id"): vol.Any(str, None),
}
)
@websocket_api.require_admin
@callback
def websocket_set_preferences(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set AI task preferences."""
preferences = hass.data[DATA_PREFERENCES]
msg.pop("type")
msg_id = msg.pop("id")
preferences.async_set_preferences(**msg)
connection.send_result(msg_id, preferences.as_dict())

View File

@@ -0,0 +1,7 @@
{
"services": {
"generate_text": {
"service": "mdi:file-star-four-points-outline"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"domain": "ai_task",
"name": "AI Task",
"codeowners": ["@home-assistant/core"],
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/ai_task",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,19 @@
generate_text:
fields:
task_name:
example: "home summary"
required: true
selector:
text:
instructions:
example: "Generate a funny notification that garage door was left open"
required: true
selector:
text:
entity_id:
required: false
selector:
entity:
domain: ai_task
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_TEXT

View File

@@ -0,0 +1,22 @@
{
"services": {
"generate_text": {
"name": "Generate text",
"description": "Use AI to run a task that generates text.",
"fields": {
"task_name": {
"name": "Task Name",
"description": "Name of the task."
},
"instructions": {
"name": "Instructions",
"description": "Instructions on what needs to be done."
},
"entity_id": {
"name": "Entity ID",
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
"""AI tasks to be handled by agents."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
async def async_generate_text(
hass: HomeAssistant,
*,
task_name: str,
entity_id: str | None = None,
instructions: str,
) -> GenTextTaskResult:
"""Run a task in the AI Task integration."""
if entity_id is None:
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
if entity_id is None:
raise ValueError("No entity_id provided and no preferred entity set")
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
if entity is None:
raise ValueError(f"AI Task entity {entity_id} not found")
if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features:
raise ValueError(f"AI Task entity {entity_id} does not support generating text")
return await entity.internal_async_generate_text(
GenTextTask(
name=task_name,
instructions=instructions,
)
)
@dataclass(slots=True)
class GenTextTask:
"""Gen text task to be processed."""
name: str
"""Name of the task."""
instructions: str
"""Instructions on what needs to be done."""
def __str__(self) -> str:
"""Return task as a string."""
return f"<GenTextTask {self.name}: {id(self)}>"
@dataclass(slots=True)
class GenTextTaskResult:
"""Result of gen text task."""
conversation_id: str
"""Unique identifier for the conversation."""
text: str
"""Generated text."""
def as_dict(self) -> dict[str, str]:
"""Return result as a dict."""
return {
"conversation_id": self.conversation_id,
"text": self.text,
}

View File

@@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
try:
location_point_valid = await test_location(
location_point_valid = await check_location(
websession,
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
)
if not location_point_valid:
location_nearest_valid = await test_location(
location_nearest_valid = await check_location(
websession,
user_input["api_key"],
user_input["latitude"],
@@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def test_location(
async def check_location(
client: ClientSession,
api_key: str,
latitude: float,

View File

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

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant
@@ -22,6 +23,7 @@ PARALLEL_UPDATES = 1
class AmazonNotifyEntityDescription(NotifyEntityDescription):
"""Alexa Devices notify entity description."""
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
subkey: str
@@ -31,6 +33,7 @@ NOTIFY: Final = (
key="speak",
translation_key="speak",
subkey="AUDIO_PLAYER",
is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY,
method=lambda api, device, message: api.call_alexa_speak(device, message),
),
AmazonNotifyEntityDescription(
@@ -58,6 +61,7 @@ async def async_setup_entry(
for sensor_desc in NOTIFY
for serial_num in coordinator.data
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
and sensor_desc.is_supported(coordinator.data[serial_num])
)

View File

@@ -366,11 +366,11 @@ class AnthropicConversationEntity(
options = self.entry.options
try:
await chat_log.async_update_llm_data(
DOMAIN,
user_input,
await chat_log.async_provide_llm_data(
user_input.as_llm_context(DOMAIN),
options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT),
user_input.extra_system_prompt,
)
except conversation.ConverseError as err:
return err.as_conversation_result()

View File

@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"alarmdel": SensorEntityDescription(
key="alarmdel",
translation_key="alarm_delay",
entity_category=EntityCategory.DIAGNOSTIC,
),
"ambtemp": SensorEntityDescription(
key="ambtemp",
@@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="apc",
translation_key="apc_status",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"apcmodel": SensorEntityDescription(
key="apcmodel",
translation_key="apc_model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"badbatts": SensorEntityDescription(
key="badbatts",
translation_key="bad_batteries",
entity_category=EntityCategory.DIAGNOSTIC,
),
"battdate": SensorEntityDescription(
key="battdate",
@@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="cable",
translation_key="cable_type",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"cumonbatt": SensorEntityDescription(
key="cumonbatt",
@@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="date",
translation_key="date",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"dipsw": SensorEntityDescription(
key="dipsw",
translation_key="dip_switch_settings",
entity_category=EntityCategory.DIAGNOSTIC,
),
"dlowbatt": SensorEntityDescription(
key="dlowbatt",
translation_key="low_battery_signal",
entity_category=EntityCategory.DIAGNOSTIC,
),
"driver": SensorEntityDescription(
key="driver",
translation_key="driver",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"dshutd": SensorEntityDescription(
key="dshutd",
translation_key="shutdown_delay",
entity_category=EntityCategory.DIAGNOSTIC,
),
"dwake": SensorEntityDescription(
key="dwake",
translation_key="wake_delay",
entity_category=EntityCategory.DIAGNOSTIC,
),
"end apc": SensorEntityDescription(
key="end apc",
translation_key="date_and_time",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"extbatts": SensorEntityDescription(
key="extbatts",
translation_key="external_batteries",
entity_category=EntityCategory.DIAGNOSTIC,
),
"firmware": SensorEntityDescription(
key="firmware",
translation_key="firmware_version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"hitrans": SensorEntityDescription(
key="hitrans",
translation_key="transfer_high",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"hostname": SensorEntityDescription(
key="hostname",
translation_key="hostname",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"humidity": SensorEntityDescription(
key="humidity",
@@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="lastxfer",
translation_key="last_transfer",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"linefail": SensorEntityDescription(
key="linefail",
translation_key="line_failure",
entity_category=EntityCategory.DIAGNOSTIC,
),
"linefreq": SensorEntityDescription(
key="linefreq",
@@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="transfer_low",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"mandate": SensorEntityDescription(
key="mandate",
translation_key="manufacture_date",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
entity_category=EntityCategory.DIAGNOSTIC,
),
"maxlinev": SensorEntityDescription(
key="maxlinev",
@@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = {
"maxtime": SensorEntityDescription(
key="maxtime",
translation_key="max_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
"mbattchg": SensorEntityDescription(
key="mbattchg",
translation_key="max_battery_charge",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"minlinev": SensorEntityDescription(
key="minlinev",
@@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = {
"mintimel": SensorEntityDescription(
key="mintimel",
translation_key="min_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
"model": SensorEntityDescription(
key="model",
translation_key="model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nombattv": SensorEntityDescription(
key="nombattv",
translation_key="battery_nominal_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nominv": SensorEntityDescription(
key="nominv",
translation_key="nominal_input_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nomoutv": SensorEntityDescription(
key="nomoutv",
translation_key="nominal_output_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nompower": SensorEntityDescription(
key="nompower",
translation_key="nominal_output_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nomapnt": SensorEntityDescription(
key="nomapnt",
translation_key="nominal_apparent_power",
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
device_class=SensorDeviceClass.APPARENT_POWER,
entity_category=EntityCategory.DIAGNOSTIC,
),
"numxfers": SensorEntityDescription(
key="numxfers",
@@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="reg1",
translation_key="register_1_fault",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"reg2": SensorEntityDescription(
key="reg2",
translation_key="register_2_fault",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"reg3": SensorEntityDescription(
key="reg3",
translation_key="register_3_fault",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"retpct": SensorEntityDescription(
key="retpct",
translation_key="restore_capacity",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
"selftest": SensorEntityDescription(
key="selftest",
@@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="sense",
translation_key="sensitivity",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"serialno": SensorEntityDescription(
key="serialno",
translation_key="serial_number",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
"statflag": SensorEntityDescription(
key="statflag",
translation_key="online_status",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"status": SensorEntityDescription(
key="status",
@@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"stesti": SensorEntityDescription(
key="stesti",
translation_key="self_test_interval",
entity_category=EntityCategory.DIAGNOSTIC,
),
"timeleft": SensorEntityDescription(
key="timeleft",
@@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="upsname",
translation_key="ups_name",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"version": SensorEntityDescription(
key="version",
translation_key="version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
entity_category=EntityCategory.DIAGNOSTIC,
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
entity_category=EntityCategory.DIAGNOSTIC,
),
}

View File

@@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner):
def get_aruba_data(self) -> dict[str, dict[str, str]] | None:
"""Retrieve data from Aruba Access Point and return parsed result."""
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
connect = f"ssh {self.username}@{self.host}"
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
query = ssh.expect(
[

View File

@@ -1,13 +1,23 @@
"""Base class for assist satellite entities."""
from dataclasses import asdict
import logging
from pathlib import Path
from typing import Any
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
PUNCTUATION_START,
PUNCTUATION_START_WORD,
)
import voluptuous as vol
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -23,6 +33,7 @@ from .const import (
)
from .entity import (
AssistSatelliteAnnouncement,
AssistSatelliteAnswer,
AssistSatelliteConfiguration,
AssistSatelliteEntity,
AssistSatelliteEntityDescription,
@@ -34,6 +45,7 @@ from .websocket_api import async_register_websocket_api
__all__ = [
"DOMAIN",
"AssistSatelliteAnnouncement",
"AssistSatelliteAnswer",
"AssistSatelliteConfiguration",
"AssistSatelliteEntity",
"AssistSatelliteEntityDescription",
@@ -86,6 +98,62 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_internal_start_conversation",
[AssistSatelliteEntityFeature.START_CONVERSATION],
)
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
"""Handle a Show View service call."""
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
satellite_entity_id
)
if satellite_entity is None:
raise HomeAssistantError(
f"Invalid Assist satellite entity id: {satellite_entity_id}"
)
ask_question_args = {
"question": call.data.get("question"),
"question_media_id": call.data.get("question_media_id"),
"preannounce": call.data.get("preannounce", False),
"answers": call.data.get("answers"),
}
if preannounce_media_id := call.data.get("preannounce_media_id"):
ask_question_args["preannounce_media_id"] = preannounce_media_id
answer = await satellite_entity.async_internal_ask_question(**ask_question_args)
if answer is None:
raise HomeAssistantError("No answer from satellite")
return asdict(answer)
hass.services.async_register(
domain=DOMAIN,
service="ask_question",
service_func=handle_ask_question,
schema=vol.All(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
vol.Optional("question"): str,
vol.Optional("question_media_id"): str,
vol.Optional("preannounce"): bool,
vol.Optional("preannounce_media_id"): str,
vol.Optional("answers"): [
{
vol.Required("id"): str,
vol.Required("sentences"): vol.All(
cv.ensure_list,
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
),
}
],
},
cv.has_at_least_one_key("question", "question_media_id"),
),
supports_response=SupportsResponse.ONLY,
)
hass.data[CONNECTION_TEST_DATA] = {}
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
@@ -110,3 +178,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
or PUNCTUATION_START_WORD.search(sentence)
or PUNCTUATION_END_WORD.search(sentence)
):
raise vol.Invalid("sentence should not contain punctuation")
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
raise vol.Invalid("at least one sentence is required")
for sentence in value:
if not sentence:
raise vol.Invalid("sentences cannot be empty")
return value

View File

@@ -4,12 +4,16 @@ from abc import abstractmethod
import asyncio
from collections.abc import AsyncIterable
import contextlib
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
import logging
import time
from typing import Any, Literal, final
from hassil import Intents, recognize
from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import WildcardSlotList
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
OPTION_PREFERRED,
@@ -105,6 +109,20 @@ class AssistSatelliteAnnouncement:
"""Media ID to be played before announcement."""
@dataclass
class AssistSatelliteAnswer:
"""Answer to a question."""
id: str | None
"""Matched answer id or None if no answer was matched."""
sentence: str
"""Raw sentence text from user response."""
slots: dict[str, Any] = field(default_factory=dict)
"""Matched slots from answer."""
class AssistSatelliteEntity(entity.Entity):
"""Entity encapsulating the state and functionality of an Assist satellite."""
@@ -122,6 +140,7 @@ class AssistSatelliteEntity(entity.Entity):
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
_attr_tts_options: dict[str, Any] | None = None
_pipeline_task: asyncio.Task | None = None
_ask_question_future: asyncio.Future[str | None] | None = None
__assist_satellite_state = AssistSatelliteState.IDLE
@@ -309,6 +328,112 @@ class AssistSatelliteEntity(entity.Entity):
"""Start a conversation from the satellite."""
raise NotImplementedError
async def async_internal_ask_question(
self,
question: str | None = None,
question_media_id: str | None = None,
preannounce: bool = True,
preannounce_media_id: str = PREANNOUNCE_URL,
answers: list[dict[str, Any]] | None = None,
) -> AssistSatelliteAnswer | None:
"""Ask a question and get a user's response from the satellite.
If question_media_id is not provided, question is synthesized to audio
with the selected pipeline.
If question_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
If preannounce is True, a sound is played before the start message or media.
If preannounce_media_id is provided, it overrides the default sound.
Calls async_start_conversation.
"""
await self._cancel_running_pipeline()
if question is None:
question = ""
announcement = await self._resolve_announcement_media_id(
question,
question_media_id,
preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:
raise SatelliteBusyError
self._is_announcing = True
self._set_state(AssistSatelliteState.RESPONDING)
self._ask_question_future = asyncio.Future()
try:
# Wait for announcement to finish
await self.async_start_conversation(announcement)
# Wait for response text
response_text = await self._ask_question_future
if response_text is None:
raise HomeAssistantError("No answer from question")
if not answers:
return AssistSatelliteAnswer(id=None, sentence=response_text)
return self._question_response_to_answer(response_text, answers)
finally:
self._is_announcing = False
self._set_state(AssistSatelliteState.IDLE)
self._ask_question_future = None
def _question_response_to_answer(
self, response_text: str, answers: list[dict[str, Any]]
) -> AssistSatelliteAnswer:
"""Match text to a pre-defined set of answers."""
# Build intents and match
intents = Intents.from_dict(
{
"language": self.hass.config.language,
"intents": {
"QuestionIntent": {
"data": [
{
"sentences": answer["sentences"],
"metadata": {"answer_id": answer["id"]},
}
for answer in answers
]
}
},
}
)
# Assume slot list references are wildcards
wildcard_names: set[str] = set()
for intent in intents.intents.values():
for intent_data in intent.data:
for sentence in intent_data.sentences:
_collect_list_references(sentence, wildcard_names)
for wildcard_name in wildcard_names:
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
# Match response text
result = recognize(response_text, intents)
if result is None:
# No match
return AssistSatelliteAnswer(id=None, sentence=response_text)
assert result.intent_metadata
return AssistSatelliteAnswer(
id=result.intent_metadata["answer_id"],
sentence=response_text,
slots={
entity_name: entity.value
for entity_name, entity in result.entities.items()
},
)
async def async_accept_pipeline_from_satellite(
self,
audio_stream: AsyncIterable[bytes],
@@ -351,6 +476,11 @@ class AssistSatelliteEntity(entity.Entity):
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
return
if (self._ask_question_future is not None) and (
start_stage == PipelineStage.STT
):
end_stage = PipelineStage.STT
device_id = self.registry_entry.device_id if self.registry_entry else None
# Refresh context if necessary
@@ -433,6 +563,16 @@ class AssistSatelliteEntity(entity.Entity):
self._set_state(AssistSatelliteState.IDLE)
elif event.type is PipelineEventType.STT_START:
self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.STT_END:
# Intercepting text for ask question
if (
(self._ask_question_future is not None)
and (not self._ask_question_future.done())
and event.data
):
self._ask_question_future.set_result(
event.data.get("stt_output", {}).get("text")
)
elif event.type is PipelineEventType.INTENT_START:
self._set_state(AssistSatelliteState.PROCESSING)
elif event.type is PipelineEventType.TTS_START:
@@ -443,6 +583,12 @@ class AssistSatelliteEntity(entity.Entity):
if not self._run_has_tts:
self._set_state(AssistSatelliteState.IDLE)
if (self._ask_question_future is not None) and (
not self._ask_question_future.done()
):
# No text for ask question
self._ask_question_future.set_result(None)
self.on_pipeline_event(event)
@callback
@@ -577,3 +723,15 @@ class AssistSatelliteEntity(entity.Entity):
media_id_source=media_id_source,
preannounce_media_id=preannounce_media_id,
)
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Sequence):
seq: Sequence = expression
for item in seq.items:
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}
list_ref: ListReference = expression
list_names.add(list_ref.slot_name)

View File

@@ -10,6 +10,9 @@
},
"start_conversation": {
"service": "mdi:forum"
},
"ask_question": {
"service": "mdi:microphone-question"
}
}
}

View File

@@ -5,5 +5,6 @@
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal"
"quality_scale": "internal",
"requirements": ["hassil==2.2.3"]
}

View File

@@ -54,3 +54,35 @@ start_conversation:
required: false
selector:
text:
ask_question:
fields:
entity_id:
required: true
selector:
entity:
domain: assist_satellite
supported_features:
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
question:
required: false
example: "What kind of music would you like to play?"
default: ""
selector:
text:
question_media_id:
required: false
selector:
text:
preannounce:
required: false
default: true
selector:
boolean:
preannounce_media_id:
required: false
selector:
text:
answers:
required: false
selector:
object:

View File

@@ -59,6 +59,36 @@
"description": "Custom media ID to play before the start message or media."
}
}
},
"ask_question": {
"name": "Ask question",
"description": "Asks a question and gets the user's response.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Assist satellite entity to ask the question on."
},
"question": {
"name": "Question",
"description": "The question to ask."
},
"question_media_id": {
"name": "Question media ID",
"description": "The media ID of the question to use instead of text-to-speech."
},
"preannounce": {
"name": "Preannounce",
"description": "Play a sound before the start message or media."
},
"preannounce_media_id": {
"name": "Preannounce media ID",
"description": "Custom media ID to play before the start message or media."
},
"answers": {
"name": "Answers",
"description": "Possible answers to the question."
}
}
}
}
}

View File

@@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints"
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
"""Return True if any automation references the blueprint."""
from . import automations_with_blueprint # pylint: disable=import-outside-toplevel
from . import automations_with_blueprint # noqa: PLC0415
return len(automations_with_blueprint(hass, blueprint_path)) > 0
@@ -28,8 +28,7 @@ async def _reload_blueprint_automations(
@callback
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
"""Get automation blueprints."""
# pylint: disable-next=import-outside-toplevel
from .config import AUTOMATION_BLUEPRINT_SCHEMA
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
return blueprint.DomainBlueprints(
hass,

View File

@@ -94,8 +94,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not with_hassio:
reader_writer = CoreBackupReaderWriter(hass)
else:
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
# pylint: disable-next=hass-component-root-import
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
SupervisorBackupReaderWriter,
)
reader_writer = SupervisorBackupReaderWriter(hass)

View File

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

View File

@@ -240,6 +240,10 @@ async def _async_get_stream_image(
height: int | None = None,
wait_for_next_keyframe: bool = False,
) -> bytes | None:
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
image := await provider.async_get_image(camera, width=width, height=height)
) is not None:
return image
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
camera.stream = await camera.async_create_stream()
if camera.stream:

View File

@@ -156,6 +156,15 @@ class CameraWebRTCProvider(ABC):
"""Close the session."""
return ## This is an optional method so we need a default here.
async def async_get_image(
self,
camera: Camera,
width: int | None = None,
height: int | None = None,
) -> bytes | None:
"""Get an image from the camera."""
return None
@callback
def async_register_webrtc_provider(

View File

@@ -105,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
# Can be removed in 2025.1 after deprecation period of the new feature flags
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
)
SET_TEMPERATURE_SCHEMA = vol.All(
cv.has_at_least_one_key(
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW

View File

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

View File

@@ -54,10 +54,10 @@ class Control4RuntimeData:
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
async def call_c4_api_retry(func, *func_args):
async def call_c4_api_retry(func, *func_args): # noqa: RET503
"""Call C4 API function and retry on failure."""
# Ruff doesn't understand this loop - the exception is always raised after the retries
for i in range(API_RETRY_TIMES): # noqa: RET503
for i in range(API_RETRY_TIMES):
try:
return await func(*func_args)
except client_exceptions.ClientError as exception:

View File

@@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
# Temporary migration. We can remove this in 2024.10
from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
async_migrate_engine,
)

View File

@@ -14,12 +14,11 @@ 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.helpers import chat_session, frame, 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_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
@@ -359,7 +358,7 @@ class ChatLog:
self,
llm_context: llm.LLMContext,
prompt: str,
language: str,
language: str | None,
user_name: str | None = None,
) -> str:
try:
@@ -373,7 +372,7 @@ class ChatLog:
)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=language)
intent_response = intent.IntentResponse(language=language or "")
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
@@ -392,15 +391,25 @@ class ChatLog:
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,
frame.report_usage(
"ChatLog.async_update_llm_data",
breaks_in_ha_version="2026.1",
)
return await self.async_provide_llm_data(
llm_context=user_input.as_llm_context(conversing_domain),
user_llm_hass_api=user_llm_hass_api,
user_llm_prompt=user_llm_prompt,
user_extra_system_prompt=user_input.extra_system_prompt,
)
async def async_provide_llm_data(
self,
llm_context: llm.LLMContext,
user_llm_hass_api: str | list[str] | None = None,
user_llm_prompt: str | None = None,
user_extra_system_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None
if user_llm_hass_api:
@@ -414,10 +423,12 @@ class ChatLog:
LOGGER.error(
"Error getting LLM API %s for %s: %s",
user_llm_hass_api,
conversing_domain,
llm_context.platform,
err,
)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response = intent.IntentResponse(
language=llm_context.language or ""
)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API",
@@ -431,10 +442,10 @@ class ChatLog:
user_name: str | None = None
if (
user_input.context
and user_input.context.user_id
llm_context.context
and llm_context.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
user := await self.hass.auth.async_get_user(llm_context.context.user_id)
)
):
user_name = user.name
@@ -444,7 +455,7 @@ class ChatLog:
await self._async_expand_prompt_template(
llm_context,
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
user_input.language,
llm_context.language,
user_name,
)
)
@@ -456,14 +467,14 @@ class ChatLog:
await self._async_expand_prompt_template(
llm_context,
llm.BASE_PROMPT,
user_input.language,
llm_context.language,
user_name,
)
)
if extra_system_prompt := (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt
user_extra_system_prompt or self.extra_system_prompt
):
prompt_parts.append(extra_system_prompt)

View File

@@ -7,7 +7,9 @@ from dataclasses import dataclass
from typing import Any, Literal
from homeassistant.core import Context
from homeassistant.helpers import intent
from homeassistant.helpers import intent, llm
from .const import DOMAIN
@dataclass(frozen=True)
@@ -56,6 +58,16 @@ class ConversationInput:
"extra_system_prompt": self.extra_system_prompt,
}
def as_llm_context(self, conversing_domain: str) -> llm.LLMContext:
"""Return input as an LLM context."""
return llm.LLMContext(
platform=conversing_domain,
context=self.context,
language=self.language,
assistant=DOMAIN,
device_id=self.device_id,
)
@dataclass(slots=True)
class ConversationResult:

View File

@@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
if (features := self._attr_supported_features) is not None:
if type(features) is int:
new_features = CoverEntityFeature(features)
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
supported_features = (

View File

@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceEntry
from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS
from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
@@ -32,10 +32,16 @@ async def async_setup_entry(
credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid)
if not credentials_valid:
raise ConfigEntryAuthFailed
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
)
if await hass.async_add_executor_job(mydevolo.maintenance):
raise ConfigEntryNotReady
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="maintenance",
)
gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids)
@@ -69,7 +75,11 @@ async def async_setup_entry(
)
)
except GatewayOfflineError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_failed",
translation_placeholders={"gateway_id": gateway_id},
) from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -91,7 +101,9 @@ async def async_unload_entry(
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
hass: HomeAssistant,
config_entry: DevoloHomeControlConfigEntry,
device_entry: DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return True

View File

@@ -45,5 +45,16 @@
"name": "Brightness"
}
}
},
"exceptions": {
"connection_failed": {
"message": "Failed to connect to devolo Home Control central unit {gateway_id}."
},
"invalid_auth": {
"message": "Authentication failed. Please re-authenticaticate with your mydevolo account."
},
"maintenance": {
"message": "devolo Home Control is currently in maintenance mode."
}
}
}

View File

@@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
):
"""Representation of a devolo device tracker."""
_attr_has_entity_name = True
_attr_translation_key = "device_tracker"
def __init__(
@@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
super().__init__(coordinator)
self._device = device
self._attr_mac_address = mac
self._attr_name = mac
@property
def extra_state_attributes(self) -> dict[str, str]:

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["discord"],
"requirements": ["nextcord==2.6.0"]
"requirements": ["nextcord==3.1.0"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.4.0"]
"requirements": ["aiodns==3.5.0"]
}

View File

@@ -108,8 +108,7 @@ def download_file(service: ServiceCall) -> None:
_LOGGER.debug("%s -> %s", url, final_path)
with open(final_path, "wb") as fil:
for chunk in req.iter_content(1024):
fil.write(chunk)
fil.writelines(req.iter_content(1024))
_LOGGER.debug("Downloading of %s done", url)
service.hass.bus.fire(

View File

@@ -2,9 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from deebot_client.capabilities import CapabilityEvent
from deebot_client.events.base import Event
from deebot_client.events.water_info import MopAttachedEvent
from homeassistant.components.binary_sensor import (
@@ -16,15 +16,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
from .util import get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsBinarySensorEntityDescription(
class EcovacsBinarySensorEntityDescription[EventT: Event](
BinarySensorEntityDescription,
EcovacsCapabilityEntityDescription,
Generic[EventT],
):
"""Class describing Deebot binary sensor entity."""
@@ -55,7 +54,7 @@ async def async_setup_entry(
)
class EcovacsBinarySensor(
class EcovacsBinarySensor[EventT: Event](
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
BinarySensorEntity,
):

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from typing import Any
from deebot_client.capabilities import Capabilities
from deebot_client.device import Device
@@ -18,11 +18,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription
from .const import DOMAIN
CapabilityEntity = TypeVar("CapabilityEntity")
EventT = TypeVar("EventT", bound=Event)
class EcovacsEntity(Entity, Generic[CapabilityEntity]):
class EcovacsEntity[CapabilityEntityT](Entity):
"""Ecovacs entity."""
_attr_should_poll = False
@@ -32,7 +29,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
def __init__(
self,
device: Device,
capability: CapabilityEntity,
capability: CapabilityEntityT,
**kwargs: Any,
) -> None:
"""Initialize entity."""
@@ -80,7 +77,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
self._subscribe(AvailabilityEvent, on_available)
def _subscribe(
def _subscribe[EventT: Event](
self,
event_type: type[EventT],
callback: Callable[[EventT], Coroutine[Any, Any, None]],
@@ -98,13 +95,13 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
self._device.events.request_refresh(event_type)
class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]):
class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntityT]):
"""Ecovacs entity."""
def __init__(
self,
device: Device,
capability: CapabilityEntity,
capability: CapabilityEntityT,
entity_description: EntityDescription,
**kwargs: Any,
) -> None:
@@ -114,13 +111,12 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]):
@dataclass(kw_only=True, frozen=True)
class EcovacsCapabilityEntityDescription(
class EcovacsCapabilityEntityDescription[CapabilityEntityT](
EntityDescription,
Generic[CapabilityEntity],
):
"""Ecovacs entity description."""
capability_fn: Callable[[Capabilities], CapabilityEntity | None]
capability_fn: Callable[[Capabilities], CapabilityEntityT | None]
class EcovacsLegacyEntity(Entity):

View File

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

View File

@@ -4,10 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from deebot_client.capabilities import CapabilitySet
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
from deebot_client.events.base import Event
from homeassistant.components.number import (
NumberEntity,
@@ -23,16 +23,14 @@ from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
EcovacsEntity,
EventT,
)
from .util import get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsNumberEntityDescription(
class EcovacsNumberEntityDescription[EventT: Event](
NumberEntityDescription,
EcovacsCapabilityEntityDescription,
Generic[EventT],
):
"""Ecovacs number entity description."""
@@ -94,7 +92,7 @@ async def async_setup_entry(
async_add_entities(entities)
class EcovacsNumberEntity(
class EcovacsNumberEntity[EventT: Event](
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
NumberEntity,
):

View File

@@ -2,11 +2,12 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Generic
from typing import Any
from deebot_client.capabilities import CapabilitySetTypes
from deebot_client.device import Device
from deebot_client.events import WorkModeEvent
from deebot_client.events.base import Event
from deebot_client.events.water_info import WaterAmountEvent
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -15,15 +16,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
from .util import get_name_key, get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsSelectEntityDescription(
class EcovacsSelectEntityDescription[EventT: Event](
SelectEntityDescription,
EcovacsCapabilityEntityDescription,
Generic[EventT],
):
"""Ecovacs select entity description."""
@@ -66,7 +66,7 @@ async def async_setup_entry(
async_add_entities(entities)
class EcovacsSelectEntity(
class EcovacsSelectEntity[EventT: Event](
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
SelectEntity,
):

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Generic
from typing import Any
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
from deebot_client.device import Device
@@ -46,16 +46,14 @@ from .entity import (
EcovacsDescriptionEntity,
EcovacsEntity,
EcovacsLegacyEntity,
EventT,
)
from .util import get_name_key, get_options, get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsSensorEntityDescription(
class EcovacsSensorEntityDescription[EventT: Event](
EcovacsCapabilityEntityDescription,
SensorEntityDescription,
Generic[EventT],
):
"""Ecovacs sensor entity description."""

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.0.12"]
"requirements": ["pysml==0.1.5"]
}

View File

@@ -2,7 +2,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
@@ -30,16 +30,16 @@ from .entity import EheimDigitalEntity, exception_handler
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]):
class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
NumberEntityDescription
):
"""Class describing EHEIM Digital sensor entities."""
value_fn: Callable[[_DeviceT_co], float | None]
set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]]
uom_fn: Callable[[_DeviceT_co], str] | None = None
value_fn: Callable[[_DeviceT], float | None]
set_value_fn: Callable[[_DeviceT, float], Awaitable[None]]
uom_fn: Callable[[_DeviceT], str] | None = None
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -136,7 +136,7 @@ async def async_setup_entry(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the number entities for one or multiple devices."""
entities: list[EheimDigitalNumber[EheimDigitalDevice]] = []
entities: list[EheimDigitalNumber[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
@@ -163,18 +163,18 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalNumber(
EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co]
class EheimDigitalNumber[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], NumberEntity
):
"""Represent a EHEIM Digital number entity."""
entity_description: EheimDigitalNumberDescription[_DeviceT_co]
entity_description: EheimDigitalNumberDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalNumberDescription[_DeviceT_co],
device: _DeviceT,
description: EheimDigitalNumberDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital number entity."""
super().__init__(coordinator, device)

View File

@@ -2,7 +2,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
@@ -17,15 +17,15 @@ from .entity import EheimDigitalEntity, exception_handler
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]):
class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
SelectEntityDescription
):
"""Class describing EHEIM Digital select entities."""
value_fn: Callable[[_DeviceT_co], str | None]
set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]]
value_fn: Callable[[_DeviceT], str | None]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -59,7 +59,7 @@ async def async_setup_entry(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the number entities for one or multiple devices."""
entities: list[EheimDigitalSelect[EheimDigitalDevice]] = []
entities: list[EheimDigitalSelect[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
@@ -75,18 +75,18 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSelect(
EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co]
class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], SelectEntity
):
"""Represent an EHEIM Digital select entity."""
entity_description: EheimDigitalSelectDescription[_DeviceT_co]
entity_description: EheimDigitalSelectDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalSelectDescription[_DeviceT_co],
device: _DeviceT,
description: EheimDigitalSelectDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital select entity."""
super().__init__(coordinator, device)

View File

@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
@@ -20,14 +20,14 @@ from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
SensorEntityDescription
):
"""Class describing EHEIM Digital sensor entities."""
value_fn: Callable[[_DeviceT_co], float | str | None]
value_fn: Callable[[_DeviceT], float | str | None]
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -75,7 +75,7 @@ async def async_setup_entry(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
entities: list[EheimDigitalSensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities += [
@@ -91,18 +91,18 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSensor(
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
class EheimDigitalSensor[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], SensorEntity
):
"""Represent a EHEIM Digital sensor entity."""
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
entity_description: EheimDigitalSensorDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalSensorDescription[_DeviceT_co],
device: _DeviceT,
description: EheimDigitalSensorDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital number entity."""
super().__init__(coordinator, device)

View File

@@ -3,7 +3,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import time
from typing import Generic, TypeVar, final, override
from typing import Any, final, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
@@ -19,15 +19,13 @@ from .entity import EheimDigitalEntity, exception_handler
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]):
class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescription):
"""Class describing EHEIM Digital time entities."""
value_fn: Callable[[_DeviceT_co], time | None]
set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]]
value_fn: Callable[[_DeviceT], time | None]
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -79,7 +77,7 @@ async def async_setup_entry(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the time entities for one or multiple devices."""
entities: list[EheimDigitalTime[EheimDigitalDevice]] = []
entities: list[EheimDigitalTime[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
@@ -103,18 +101,18 @@ async def async_setup_entry(
@final
class EheimDigitalTime(
EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co]
class EheimDigitalTime[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], TimeEntity
):
"""Represent an EHEIM Digital time entity."""
entity_description: EheimDigitalTimeDescription[_DeviceT_co]
entity_description: EheimDigitalTimeDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalTimeDescription[_DeviceT_co],
device: _DeviceT,
description: EheimDigitalTimeDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital time entity."""
super().__init__(coordinator, device)

View File

@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.exceptions import Eq3Exception
from eq3btsmart.thermostat_config import ThermostatConfig
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
@@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
f"[{eq3_config.mac_address}] Device could not be found"
)
thermostat = Thermostat(
thermostat_config=ThermostatConfig(
mac_address=mac_address,
),
ble_device=device,
)
thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
entry.runtime_data = Eq3ConfigEntryData(
eq3_config=eq3_config, thermostat=thermostat

View File

@@ -2,7 +2,6 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart.models import Status
@@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View File

@@ -1,9 +1,16 @@
"""Platform for eQ-3 climate entities."""
from datetime import timedelta
import logging
from typing import Any
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode
from eq3btsmart.const import (
EQ3_DEFAULT_AWAY_TEMP,
EQ3_MAX_TEMP,
EQ3_OFF_TEMP,
Eq3OperationMode,
Eq3Preset,
)
from eq3btsmart.exceptions import Eq3Exception
from homeassistant.components.climate import (
@@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.util.dt as dt_util
from . import Eq3ConfigEntry
from .const import (
DEFAULT_AWAY_HOURS,
EQ_TO_HA_HVAC,
HA_TO_EQ_HVAC,
CurrentTemperatureSelector,
@@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = EQ3BT_OFF_TEMP
_attr_max_temp = EQ3BT_MAX_TEMP
_attr_min_temp = EQ3_OFF_TEMP
_attr_max_temp = EQ3_MAX_TEMP
_attr_precision = PRECISION_HALVES
_attr_hvac_modes = list(HA_TO_EQ_HVAC.keys())
_attr_preset_modes = list(Preset)
@@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
_target_temperature: float | None = None
@callback
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
if self._thermostat.status is not None:
self._async_on_status_updated()
if self._thermostat.device_data is not None:
self._async_on_device_updated()
super()._async_on_updated()
@callback
def _async_on_status_updated(self) -> None:
def _async_on_status_updated(self, data: Any) -> None:
"""Handle updated status from the thermostat."""
if self._thermostat.status is None:
return
self._target_temperature = self._thermostat.status.target_temperature.value
self._target_temperature = self._thermostat.status.target_temperature
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
self._attr_current_temperature = self._get_current_temperature()
self._attr_target_temperature = self._get_target_temperature()
self._attr_preset_mode = self._get_current_preset_mode()
self._attr_hvac_action = self._get_current_hvac_action()
super()._async_on_status_updated(data)
@callback
def _async_on_device_updated(self) -> None:
def _async_on_device_updated(self, data: Any) -> None:
"""Handle updated device data from the thermostat."""
if self._thermostat.device_data is None:
return
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
@@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
device_registry.async_update_device(
device.id,
sw_version=str(self._thermostat.device_data.firmware_version),
serial_number=self._thermostat.device_data.device_serial.value,
serial_number=self._thermostat.device_data.device_serial,
)
super()._async_on_device_updated(data)
def _get_current_temperature(self) -> float | None:
"""Return the current temperature."""
@@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case CurrentTemperatureSelector.NOTHING:
return None
case CurrentTemperatureSelector.VALVE:
if self._thermostat.status is None:
return None
return float(self._thermostat.status.valve_temperature)
case CurrentTemperatureSelector.UI:
return self._target_temperature
case CurrentTemperatureSelector.DEVICE:
if self._thermostat.status is None:
return None
return float(self._thermostat.status.target_temperature.value)
return float(self._thermostat.status.target_temperature)
case CurrentTemperatureSelector.ENTITY:
state = self.hass.states.get(self._eq3_config.external_temp_sensor)
if state is not None:
@@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case TargetTemperatureSelector.TARGET:
return self._target_temperature
case TargetTemperatureSelector.LAST_REPORTED:
if self._thermostat.status is None:
return None
return float(self._thermostat.status.target_temperature.value)
return float(self._thermostat.status.target_temperature)
def _get_current_preset_mode(self) -> str:
"""Return the current preset mode."""
if (status := self._thermostat.status) is None:
return PRESET_NONE
status = self._thermostat.status
if status.is_window_open:
return Preset.WINDOW_OPEN
if status.is_boost:
@@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
return Preset.LOW_BATTERY
if status.is_away:
return Preset.AWAY
if status.operation_mode is OperationMode.ON:
if status.operation_mode is Eq3OperationMode.ON:
return Preset.OPEN
if status.presets is None:
return PRESET_NONE
@@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
def _get_current_hvac_action(self) -> HVACAction:
"""Return the current hvac action."""
if (
self._thermostat.status is None
or self._thermostat.status.operation_mode is OperationMode.OFF
):
if self._thermostat.status.operation_mode is Eq3OperationMode.OFF:
return HVACAction.OFF
if self._thermostat.status.valve == 0:
return HVACAction.IDLE
@@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
"""Set new target hvac mode."""
if hvac_mode is HVACMode.OFF:
await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP)
await self.async_set_temperature(temperature=EQ3_OFF_TEMP)
try:
await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode])
@@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
case Preset.BOOST:
await self._thermostat.async_set_boost(True)
case Preset.AWAY:
await self._thermostat.async_set_away(True)
away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
case Preset.ECO:
await self._thermostat.async_set_preset(Eq3Preset.ECO)
case Preset.COMFORT:
await self._thermostat.async_set_preset(Eq3Preset.COMFORT)
case Preset.OPEN:
await self._thermostat.async_set_mode(OperationMode.ON)
await self._thermostat.async_set_mode(Eq3OperationMode.ON)

View File

@@ -2,7 +2,7 @@
from enum import Enum
from eq3btsmart.const import OperationMode
from eq3btsmart.const import Eq3OperationMode
from homeassistant.components.climate import (
PRESET_AWAY,
@@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until"
GET_DEVICE_TIMEOUT = 5 # seconds
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = {
OperationMode.OFF: HVACMode.OFF,
OperationMode.ON: HVACMode.HEAT,
OperationMode.AUTO: HVACMode.AUTO,
OperationMode.MANUAL: HVACMode.HEAT,
EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = {
Eq3OperationMode.OFF: HVACMode.OFF,
Eq3OperationMode.ON: HVACMode.HEAT,
Eq3OperationMode.AUTO: HVACMode.AUTO,
Eq3OperationMode.MANUAL: HVACMode.HEAT,
}
HA_TO_EQ_HVAC = {
HVACMode.OFF: OperationMode.OFF,
HVACMode.AUTO: OperationMode.AUTO,
HVACMode.HEAT: OperationMode.MANUAL,
HVACMode.OFF: Eq3OperationMode.OFF,
HVACMode.AUTO: Eq3OperationMode.AUTO,
HVACMode.HEAT: Eq3OperationMode.MANUAL,
}
@@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum):
DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE
DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET
DEFAULT_SCAN_INTERVAL = 10 # seconds
DEFAULT_AWAY_HOURS = 30 * 24
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"

View File

@@ -1,5 +1,10 @@
"""Base class for all eQ-3 entities."""
from typing import Any
from eq3btsmart import Eq3Exception
from eq3btsmart.const import Eq3Event
from homeassistant.core import callback
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
@@ -45,7 +50,15 @@ class Eq3Entity(Entity):
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated)
self._thermostat.register_callback(
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
)
self._thermostat.register_callback(
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
)
self._thermostat.register_callback(
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
)
self.async_on_remove(
async_dispatcher_connect(
@@ -65,10 +78,25 @@ class Eq3Entity(Entity):
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated)
self._thermostat.unregister_callback(
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
)
self._thermostat.unregister_callback(
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
)
self._thermostat.unregister_callback(
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
)
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
@callback
def _async_on_status_updated(self, data: Any) -> None:
"""Handle updated status from the thermostat."""
self.async_write_ha_state()
@callback
def _async_on_device_updated(self, data: Any) -> None:
"""Handle updated device data from the thermostat."""
self.async_write_ha_state()
@@ -90,4 +118,9 @@ class Eq3Entity(Entity):
def available(self) -> bool:
"""Whether the entity is available."""
return self._thermostat.status is not None and self._attr_available
try:
_ = self._thermostat.status
except Eq3Exception:
return False
return self._attr_available

View File

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

View File

@@ -1,17 +1,12 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP
from eq3btsmart.models import Presets, Status
from homeassistant.components.number import (
NumberDeviceClass,
@@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
Callable[[float], Coroutine[None, None, Status]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
@@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_func=lambda presets: presets.comfort_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_func=lambda presets: presets.eco_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_func=lambda presets: presets.window_open_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_min_value=EQ3_MIN_TEMP,
native_max_value=EQ3_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_func=lambda presets: presets.offset_temperature,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_min_value=EQ3_MIN_OFFSET,
native_max_value=EQ3_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
@@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
value_func=lambda presets: presets.window_open_time.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
@@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
@@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
super().available
and self._thermostat.status.presets is not None
and self._attr_available
)

View File

@@ -1,12 +1,12 @@
"""Voluptuous schemas for eq3btsmart."""
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP
from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP
import voluptuous as vol
from homeassistant.const import CONF_MAC
from homeassistant.helpers import config_validation as cv
SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP)
SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP)
SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string})
SCHEMA_MAC = vol.Schema(
{

View File

@@ -3,7 +3,6 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from eq3btsmart.models import Status
@@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [
Eq3SensorEntityDescription(
key=ENTITY_KEY_AWAY_UNTIL,
translation_key=ENTITY_KEY_AWAY_UNTIL,
value_func=lambda status: (
status.away_until.value if status.away_until else None
),
value_func=lambda status: (status.away_until if status.away_until else None),
device_class=SensorDeviceClass.DATE,
),
]
@@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity):
def native_value(self) -> int | datetime | None:
"""Return the value reported by the sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View File

@@ -1,26 +1,45 @@
"""Platform for eq3 switch entities."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from datetime import timedelta
from functools import partial
from typing import Any
from eq3btsmart import Thermostat
from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode
from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.util.dt as dt_util
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
from .const import (
DEFAULT_AWAY_HOURS,
ENTITY_KEY_AWAY,
ENTITY_KEY_BOOST,
ENTITY_KEY_LOCK,
)
from .entity import Eq3Entity
async def async_set_away(thermostat: Thermostat, enable: bool) -> Status:
"""Backport old async_set_away behavior."""
if not enable:
return await thermostat.async_set_mode(Eq3OperationMode.AUTO)
away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
@dataclass(frozen=True, kw_only=True)
class Eq3SwitchEntityDescription(SwitchEntityDescription):
"""Entity description for eq3 switch entities."""
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]]
value_func: Callable[[Status], bool]
@@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [
Eq3SwitchEntityDescription(
key=ENTITY_KEY_AWAY,
translation_key=ENTITY_KEY_AWAY,
toggle_func=lambda thermostat: thermostat.async_set_away,
toggle_func=lambda thermostat: partial(async_set_away, thermostat),
value_func=lambda status: status.is_away,
),
]
@@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
def is_on(self) -> bool:
"""Return the state of the switch."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View File

@@ -60,6 +60,7 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START,
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END,
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START,
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: PipelineEventType.INTENT_PROGRESS,
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END,
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START,
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END,
@@ -282,6 +283,12 @@ class EsphomeAssistSatellite(
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
assert event.data is not None
data_to_send = {"text": event.data["stt_output"]["text"]}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
data_to_send = {
"tts_start_streaming": "1"
if (event.data and event.data.get("tts_start_streaming"))
else "0",
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
assert event.data is not None
data_to_send = {
@@ -332,7 +339,7 @@ class EsphomeAssistSatellite(
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
assert event.data is not None
if tts_output := event.data["tts_output"]:
if tts_output := event.data.get("tts_output"):
path = tts_output["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}

View File

@@ -63,9 +63,7 @@ class ESPHomeDashboardManager:
if not (data := self._data) or not (info := data.get("info")):
return
if is_hassio(self._hass):
from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
get_addons_info,
)
from homeassistant.components.hassio import get_addons_info # noqa: PLC0415
if (addons := get_addons_info(self._hass)) is not None and info[
"addon_slug"

View File

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

View File

@@ -364,8 +364,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
if dev_repo_path is not None:
return pathlib.Path(dev_repo_path) / "hass_frontend"
# Keep import here so that we can import frontend without installing reqs
# pylint: disable-next=import-outside-toplevel
import hass_frontend
import hass_frontend # noqa: PLC0415
return hass_frontend.where()

View File

@@ -1,8 +1,11 @@
"""The go2rtc component."""
from __future__ import annotations
import logging
import shutil
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
@@ -32,7 +35,7 @@ from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOM
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
discovery_flow,
@@ -98,6 +101,7 @@ CONFIG_SCHEMA = vol.Schema(
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -151,13 +155,14 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
await hass.config_entries.async_remove(entry.entry_id)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC]
url = hass.data[_DATA_GO2RTC]
session = async_get_clientsession(hass)
client = Go2RtcRestClient(session, url)
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), url)
version = await client.validate_server_version()
if version < AwesomeVersion(RECOMMENDED_VERSION):
ir.async_create_issue(
@@ -188,13 +193,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
provider = WebRTCProvider(hass, url)
async_register_webrtc_provider(hass, provider)
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
"""Unload a go2rtc config entry."""
await entry.runtime_data.teardown()
return True
@@ -206,12 +212,18 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
def __init__(self, hass: HomeAssistant, url: str) -> None:
def __init__(
self,
hass: HomeAssistant,
url: str,
session: ClientSession,
rest_client: Go2RtcRestClient,
) -> None:
"""Initialize the WebRTC provider."""
self._hass = hass
self._url = url
self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, url)
self._session = session
self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {}
@property
@@ -232,32 +244,16 @@ class WebRTCProvider(CameraWebRTCProvider):
send_message: WebRTCSendMessage,
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
try:
await self._update_stream_source(camera)
except HomeAssistantError as err:
send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err)))
return
self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._url, source=camera.entity_id
)
if not (stream_source := await camera.stream_source()):
send_message(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
)
return
streams = await self._rest_client.streams.list()
if (stream := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream.producers
):
await self._rest_client.streams.add(
camera.entity_id,
[
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
@callback
def on_messages(message: ReceiveMessages) -> None:
"""Handle messages."""
@@ -291,3 +287,48 @@ class WebRTCProvider(CameraWebRTCProvider):
"""Close the session."""
ws_client = self._sessions.pop(session_id)
self._hass.async_create_task(ws_client.close())
async def async_get_image(
self,
camera: Camera,
width: int | None = None,
height: int | None = None,
) -> bytes | None:
"""Get an image from the camera."""
await self._update_stream_source(camera)
return await self._rest_client.get_jpeg_snapshot(
camera.entity_id, width, height
)
async def _update_stream_source(self, camera: Camera) -> None:
"""Update the stream source in go2rtc config if needed."""
if not (stream_source := await camera.stream_source()):
await self.teardown()
raise HomeAssistantError("Camera has no stream source")
if not self.async_is_supported(stream_source):
await self.teardown()
raise HomeAssistantError("Stream source is not supported by go2rtc")
streams = await self._rest_client.streams.list()
if (stream := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream.producers
):
await self._rest_client.streams.add(
camera.entity_id,
[
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
f"ffmpeg:{camera.entity_id}#video=mjpeg",
],
)
async def teardown(self) -> None:
"""Tear down the provider."""
for ws_client in self._sessions.values():
await ws_client.close()
self._sessions.clear()

View File

@@ -109,6 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="timestamp",
translation_key="timestamp",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"]
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"]
}

View File

@@ -212,8 +212,7 @@ class AbstractConfig(ABC):
def async_enable_report_state(self) -> None:
"""Enable proactive mode."""
# Circular dep
# pylint: disable-next=import-outside-toplevel
from .report_state import async_enable_report_state
from .report_state import async_enable_report_state # noqa: PLC0415
if self._unsub_report_state is None:
self._unsub_report_state = async_enable_report_state(self.hass, self)
@@ -395,8 +394,7 @@ class AbstractConfig(ABC):
async def _handle_local_webhook(self, hass, webhook_id, request):
"""Handle an incoming local SDK message."""
# Circular dep
# pylint: disable-next=import-outside-toplevel
from . import smart_home
from . import smart_home # noqa: PLC0415
self._local_last_active = utcnow()
@@ -655,8 +653,9 @@ class GoogleEntity:
if "matter" in self.hass.config.components and any(
x for x in device_entry.identifiers if x[0] == "matter"
):
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.matter import get_matter_device_info
from homeassistant.components.matter import ( # noqa: PLC0415
get_matter_device_info,
)
# Import matter can block the event loop for multiple seconds
# so we import it here to avoid blocking the event loop during

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from types import MappingProxyType
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -12,7 +13,7 @@ from google.genai.types import File, FileState
from requests.exceptions import Timeout
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import (
HomeAssistant,
@@ -26,16 +27,19 @@ from homeassistant.exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
FILE_POLLING_INTERVAL_SECONDS,
LOGGER,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
TIMEOUT_MILLIS,
)
@@ -45,7 +49,11 @@ CONF_IMAGE_FILENAME = "image_filename"
CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (Platform.CONVERSATION,)
PLATFORMS = (
Platform.AI_TASK,
Platform.CONVERSATION,
Platform.TTS,
)
type GoogleGenerativeAIConfigEntry = ConfigEntry[Client]
@@ -206,3 +214,51 @@ async def async_unload_entry(
return False
return True
async def async_migrate_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
"""Migrate old entry."""
if entry.version == 1:
# Migrate from version 1 to version 2
# Move conversation-specific options to a subentry
conversation_subentry = ConfigSubentry(
data=entry.options,
subentry_type="conversation",
title=DEFAULT_CONVERSATION_NAME,
unique_id=None,
)
hass.config_entries.async_add_subentry(
entry,
conversation_subentry,
)
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
subentry_type="ai_task",
title=DEFAULT_AI_TASK_NAME,
unique_id=None,
),
)
# Migrate conversation entity to be linked to subentry
ent_reg = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if entity_entry.domain == Platform.CONVERSATION:
ent_reg.async_update_entity(
entity_entry.entity_id,
config_subentry_id=conversation_subentry.subentry_id,
new_unique_id=conversation_subentry.subentry_id,
)
break
# Remove options from the main entry
hass.config_entries.async_update_entry(
entry,
options={},
version=2,
)
return True

View File

@@ -0,0 +1,55 @@
"""LLM Task integration for Google Generative AI Conversation."""
from __future__ import annotations
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LLM Task entities."""
entities = [
GoogleGenerativeAILLMTaskEntity(config_entry, subentry)
for subentry in config_entry.subentries.values()
if subentry.subentry_type == "ai_task"
]
async_add_entities(entities)
class GoogleGenerativeAILLMTaskEntity(
ai_task.AITaskEntity,
GoogleGenerativeAILLMBaseEntity,
):
"""Google Generative AI AI Task entity."""
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_TEXT
async def _async_generate_text(
self,
task: ai_task.GenTextTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenTextTaskResult:
"""Handle a generate text task."""
await self._async_handle_chat_log(chat_log)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
LOGGER.error(
"Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response",
chat_log.content[-1],
)
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
return ai_task.GenTextTaskResult(
conversation_id=chat_log.conversation_id,
text=chat_log.content[-1].content or "",
)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
from types import MappingProxyType
from typing import Any
from google import genai
@@ -17,10 +16,11 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
NumberSelector,
@@ -45,8 +45,12 @@ from .const import (
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
@@ -64,12 +68,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -90,7 +88,7 @@ async def validate_input(data: dict[str, Any]) -> None:
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Generative AI Conversation."""
VERSION = 1
VERSION = 2
async def async_step_api(
self, user_input: dict[str, Any] | None = None
@@ -117,7 +115,20 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title="Google Generative AI",
data=user_input,
options=RECOMMENDED_OPTIONS,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
step_id="api",
@@ -156,58 +167,97 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
@staticmethod
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlow:
"""Create the options flow."""
return GoogleGenerativeAIOptionsFlow(config_entry)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {
"conversation": LLMSubentryFlowHandler,
"ai_task": LLMSubentryFlowHandler,
}
class GoogleGenerativeAIOptionsFlow(OptionsFlow):
"""Google Generative AI config flow options handler."""
class LLMSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False
)
self._genai_client = config_entry.runtime_data
last_rendered_recommended = False
is_new: bool
start_data: dict[str, Any]
async def async_step_init(
@property
def _genai_client(self) -> genai.Client:
"""Return the Google Generative AI client."""
return self._get_entry().runtime_data
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
) -> SubentryFlowResult:
"""Add a subentry."""
self.is_new = True
if self._subentry_type == "ai_task":
self.start_data = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.start_data = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_set_options()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of a subentry."""
self.is_new = False
self.start_data = self._get_reconfigure_subentry().data.copy()
return await self.async_step_set_options()
async def async_step_set_options(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Set conversation options."""
options = self.start_data
errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
# Don't allow to save options that enable the Google Search tool with an Assist API
if not (
user_input.get(CONF_LLM_HASS_API)
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
):
# Don't allow to save options that enable the Google Seearch tool with an Assist API
return self.async_create_entry(title="", data=user_input)
if self.is_new:
return self.async_create_entry(
title=user_input.pop(CONF_NAME),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = user_input
else:
self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False)
schema = await google_generative_ai_config_option_schema(
self.hass, options, self._genai_client
self.hass, self.is_new, self._subentry_type, options, self._genai_client
)
return self.async_show_form(
step_id="init", data_schema=vol.Schema(schema), errors=errors
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
)
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
is_new: bool,
subentry_type: str,
options: Mapping[str, Any],
genai_client: genai.Client,
) -> dict:
@@ -224,29 +274,57 @@ async def google_generative_ai_config_option_schema(
):
suggested_llm_apis = [suggested_llm_apis]
schema = {
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
}
if is_new:
if CONF_NAME in options:
default_name = options[CONF_NAME]
elif subentry_type == "ai_task":
default_name = DEFAULT_AI_TASK_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default=default_name): str,
}
else:
schema = {}
if subentry_type == "conversation":
schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": suggested_llm_apis},
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
}
)
else:
# For ai_task and tts subentry types
schema.update(
{
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
}
)
if options.get(CONF_RECOMMENDED):
return schema
api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
api_models = [api_model async for api_model in api_models_pager]
models = [
SelectOptionDict(
label=api_model.display_name,
@@ -342,13 +420,17 @@ async def google_generative_ai_config_option_schema(
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type == "conversation":
schema[
vol.Optional(
CONF_USE_GOOGLE_SEARCH_TOOL,
description={
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
},
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
): bool,
}
)
)
] = bool
return schema

View File

@@ -2,13 +2,22 @@
import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
DOMAIN = "google_generative_ai_conversation"
LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google Conversation"
DEFAULT_AI_TASK_NAME = "Google AI Task"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
@@ -27,3 +36,13 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}

View File

@@ -2,63 +2,18 @@
from __future__ import annotations
import codecs
from collections.abc import AsyncGenerator, Callable
from dataclasses import replace
from typing import Any, Literal, cast
from google.genai.errors import APIError, ClientError
from google.genai.types import (
AutomaticFunctionCallingConfig,
Content,
FunctionDeclaration,
GenerateContentConfig,
GenerateContentResponse,
GoogleSearch,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from voluptuous_openapi import convert
from typing import Literal
from homeassistant.components import assist_pipeline, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
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 intent
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
ERROR_GETTING_RESPONSE = (
"Sorry, I had a problem getting a response from Google Generative AI."
)
from .const import CONF_PROMPT, DOMAIN, LOGGER
from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
async def async_setup_entry(
@@ -67,272 +22,29 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
agent = GoogleGenerativeAIConversationEntity(config_entry)
async_add_entities([agent])
SUPPORTED_SCHEMA_KEYS = {
# Gemini API does not support all of the OpenAPI schema
# SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
"max_items",
"min_items",
"properties",
"required",
"items",
}
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
def _format_schema(schema: dict[str, Any]) -> Schema:
"""Format the schema to be compatible with Gemini API."""
if subschemas := schema.get("allOf"):
for subschema in subschemas: # Gemini API does not support allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
subschemas[0]
) # Or, if not found, to any of the subschemas
result = {}
for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "conversation":
continue
if key == "type":
val = val.upper()
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type": "STRING"}}
result["required"] = []
return cast(Schema, result)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> Tool:
"""Format tool specification."""
if tool.parameters.schema:
parameters = _format_schema(
convert(tool.parameters, custom_serializer=custom_serializer)
async_add_entities(
[GoogleGenerativeAIConversationEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
else:
parameters = None
return Tool(
function_declarations=[
FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters=parameters,
)
]
)
def _escape_decode(value: Any) -> Any:
"""Recursively call codecs.escape_decode on all values."""
if isinstance(value, str):
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
return {k: _escape_decode(v) for k, v in value.items()}
return value
def _create_google_tool_response_parts(
parts: list[conversation.ToolResultContent],
) -> list[Part]:
"""Create Google tool response parts."""
return [
Part.from_function_response(
name=tool_result.tool_name, response=tool_result.tool_result
)
for tool_result in parts
]
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> Content:
"""Create a Google tool response content."""
return Content(
role="user",
parts=_create_google_tool_response_parts(content),
)
def _convert_content(
content: (
conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent
),
) -> Content:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
if content.content:
parts.append(Part.from_text(text=content.content))
if content.tool_calls:
parts.extend(
[
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
return Content(role="model", parts=parts)
async def _transform_stream(
result: AsyncGenerator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
chunk["role"] = "assistant"
new_message = False
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
reason = (
response.prompt_feedback.block_reason_message
if response.prompt_feedback
else "unknown"
)
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {reason}"
)
candidate = response.candidates[0]
if (
candidate.finish_reason is not None
and candidate.finish_reason != "STOP"
):
# The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason
LOGGER.error(
"Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason",
candidate.finish_reason,
)
raise HomeAssistantError(
f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}"
)
response_parts = (
candidate.content.parts
if candidate.content is not None and candidate.content.parts is not None
else []
)
content = "".join([part.text for part in response_parts if part.text])
tool_calls = []
for part in response_parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
if tool_calls:
chunk["tool_calls"] = tool_calls
chunk["content"] = content
yield chunk
except (
APIError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
if isinstance(err, APIError):
message = err.message
else:
message = type(err).__name__
error = f"{ERROR_GETTING_RESPONSE}: {message}"
raise HomeAssistantError(error) from err
class GoogleGenerativeAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
conversation.ConversationEntity,
conversation.AbstractConversationAgent,
GoogleGenerativeAILLMBaseEntity,
):
"""Google Generative AI conversation agent."""
_attr_has_entity_name = True
_attr_name = None
_attr_supports_streaming = True
def __init__(self, entry: ConfigEntry) -> None:
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent."""
self.entry = entry
self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.entry.options.get(CONF_LLM_HASS_API):
super().__init__(entry, subentry)
if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
@@ -358,27 +70,20 @@ class GoogleGenerativeAIConversationEntity(
conversation.async_unset_agent(self.hass, self.entry)
await super().async_will_remove_from_hass()
def _fix_tool_name(self, tool_name: str) -> str:
"""Fix tool name if needed."""
# The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
# name. This makes sure when it incorrectly changes the name, that we change it
# back for HA to call.
return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Call the API."""
options = self.entry.options
options = self.subentry.data
try:
await chat_log.async_update_llm_data(
DOMAIN,
user_input,
await chat_log.async_provide_llm_data(
user_input.as_llm_context(DOMAIN),
options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT),
user_input.extra_system_prompt,
)
except conversation.ConverseError as err:
return err.as_conversation_result()
@@ -391,7 +96,7 @@ class GoogleGenerativeAIConversationEntity(
"Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response",
chat_log.content[-1],
)
raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}")
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
response.async_set_speech(chat_log.content[-1].content or "")
return conversation.ConversationResult(
response=response,
@@ -399,163 +104,6 @@ class GoogleGenerativeAIConversationEntity(
continue_conversation=chat_log.continue_conversation,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.entry.options
tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
# Using search grounding allows the model to retrieve information from the web,
# however, it may interfere with how the model decides to use some tools, or entities
# for example weather entity may be disregarded if the model chooses to Google it.
if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True:
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
and "gemini-2.0-flash-preview-image-generation" not in model_name
)
prompt_content = cast(
conversation.SystemContent,
chat_log.content[0],
)
if prompt_content.content:
prompt = prompt_content.content
else:
raise HomeAssistantError("Invalid prompt content")
messages: list[Content] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:-1]:
if chat_content.role == "tool_result":
tool_results.append(chat_content)
continue
if (
not isinstance(chat_content, conversation.ToolResultContent)
and chat_content.content == ""
):
# Skipping is not possible since the number of function calls need to match the number of function responses
# and skipping one would mean removing the other and hence this would prevent a proper chat log
chat_content = replace(chat_content, content=" ")
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(_convert_content(chat_content))
# The SDK requires the first message to be a user message
# This is not the case if user used `start_conversation`
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
if messages and messages[0].role != "user":
messages.insert(
0,
Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
messages = [
Content(role="user", parts=[Part.from_text(text=prompt)]),
Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
chat_request: str | list[Part] = user_message.content
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request
)
except (
APIError,
ClientError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
error = ERROR_GETTING_RESPONSE
raise HomeAssistantError(error) from err
chat_request = _create_google_tool_response_parts(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_response_generator),
)
if isinstance(content, conversation.ToolResultContent)
]
)
if not chat_log.unresponded_tool_results:
break
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:

View File

@@ -0,0 +1,475 @@
"""Conversation support for the Google Generative AI Conversation integration."""
from __future__ import annotations
import codecs
from collections.abc import AsyncGenerator, Callable
from dataclasses import replace
from typing import Any, cast
from google.genai.errors import APIError, ClientError
from google.genai.types import (
AutomaticFunctionCallingConfig,
Content,
FunctionDeclaration,
GenerateContentConfig,
GenerateContentResponse,
GoogleSearch,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from .const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
ERROR_GETTING_RESPONSE = (
"Sorry, I had a problem getting a response from Google Generative AI."
)
SUPPORTED_SCHEMA_KEYS = {
# Gemini API does not support all of the OpenAPI schema
# SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
"max_items",
"min_items",
"properties",
"required",
"items",
}
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
def _format_schema(schema: dict[str, Any]) -> Schema:
"""Format the schema to be compatible with Gemini API."""
if subschemas := schema.get("allOf"):
for subschema in subschemas: # Gemini API does not support allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
subschemas[0]
) # Or, if not found, to any of the subschemas
result = {}
for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "type":
val = val.upper()
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type": "STRING"}}
result["required"] = []
return cast(Schema, result)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> Tool:
"""Format tool specification."""
if tool.parameters.schema:
parameters = _format_schema(
convert(tool.parameters, custom_serializer=custom_serializer)
)
else:
parameters = None
return Tool(
function_declarations=[
FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters=parameters,
)
]
)
def _escape_decode(value: Any) -> Any:
"""Recursively call codecs.escape_decode on all values."""
if isinstance(value, str):
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
return {k: _escape_decode(v) for k, v in value.items()}
return value
def _create_google_tool_response_parts(
parts: list[conversation.ToolResultContent],
) -> list[Part]:
"""Create Google tool response parts."""
return [
Part.from_function_response(
name=tool_result.tool_name, response=tool_result.tool_result
)
for tool_result in parts
]
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> Content:
"""Create a Google tool response content."""
return Content(
role="user",
parts=_create_google_tool_response_parts(content),
)
def _convert_content(
content: (
conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent
),
) -> Content:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
if content.content:
parts.append(Part.from_text(text=content.content))
if content.tool_calls:
parts.extend(
[
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
return Content(role="model", parts=parts)
async def _transform_stream(
result: AsyncGenerator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
chunk["role"] = "assistant"
new_message = False
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
reason = (
response.prompt_feedback.block_reason_message
if response.prompt_feedback
else "unknown"
)
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {reason}"
)
candidate = response.candidates[0]
if (
candidate.finish_reason is not None
and candidate.finish_reason != "STOP"
):
# The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason
LOGGER.error(
"Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason",
candidate.finish_reason,
)
raise HomeAssistantError(
f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}"
)
response_parts = (
candidate.content.parts
if candidate.content is not None and candidate.content.parts is not None
else []
)
content = "".join([part.text for part in response_parts if part.text])
tool_calls = []
for part in response_parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
if tool_calls:
chunk["tool_calls"] = tool_calls
chunk["content"] = content
yield chunk
except (
APIError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
if isinstance(err, APIError):
message = err.message
else:
message = type(err).__name__
error = f"{ERROR_GETTING_RESPONSE}: {message}"
raise HomeAssistantError(error) from err
class GoogleGenerativeAILLMBaseEntity(Entity):
"""Google Generative AI base entity."""
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title or DEFAULT_CONVERSATION_NAME
self._genai_client = entry.runtime_data
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
# Using search grounding allows the model to retrieve information from the web,
# however, it may interfere with how the model decides to use some tools, or entities
# for example weather entity may be disregarded if the model chooses to Google it.
if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True:
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
supports_system_instruction = (
"gemma" not in model_name
and "gemini-2.0-flash-preview-image-generation" not in model_name
)
prompt_content = cast(
conversation.SystemContent,
chat_log.content[0],
)
if prompt_content.content:
prompt = prompt_content.content
else:
raise HomeAssistantError("Invalid prompt content")
messages: list[Content] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:-1]:
if chat_content.role == "tool_result":
tool_results.append(chat_content)
continue
if (
not isinstance(chat_content, conversation.ToolResultContent)
and chat_content.content == ""
):
# Skipping is not possible since the number of function calls need to match the number of function responses
# and skipping one would mean removing the other and hence this would prevent a proper chat log
chat_content = replace(chat_content, content=" ")
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(_convert_content(chat_content))
# The SDK requires the first message to be a user message
# This is not the case if user used `start_conversation`
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
if messages and messages[0].role != "user":
messages.insert(
0,
Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
),
],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
messages = [
Content(role="user", parts=[Part.from_text(text=prompt)]),
Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
chat_request: str | list[Part] = user_message.content
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request
)
except (
APIError,
ClientError,
ValueError,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
error = ERROR_GETTING_RESPONSE
raise HomeAssistantError(error) from err
chat_request = _create_google_tool_response_parts(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_response_generator),
)
if isinstance(content, conversation.ToolResultContent)
]
)
if not chat_log.unresponded_tool_results:
break

View File

@@ -21,32 +21,71 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
"step": {
"init": {
"data": {
"recommended": "Recommended model settings",
"prompt": "Instructions",
"chat_model": "[%key:common::generic::model%]",
"temperature": "Temperature",
"top_p": "Top P",
"top_k": "Top K",
"max_tokens": "Maximum tokens to return in response",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts",
"enable_google_search_tool": "Enable Google Search tool"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
"config_subentries": {
"conversation": {
"initiate_flow": {
"user": "Add conversation agent",
"reconfigure": "Reconfigure conversation agent"
},
"entry_type": "Conversation agent",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "Recommended model settings",
"prompt": "Instructions",
"chat_model": "[%key:common::generic::model%]",
"temperature": "Temperature",
"top_p": "Top P",
"top_k": "Top K",
"max_tokens": "Maximum tokens to return in response",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts",
"enable_google_search_tool": "Enable Google Search tool"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
}
}
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
}
},
"error": {
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
"ai_task": {
"initiate_flow": {
"user": "Add AI task service",
"reconfigure": "Reconfigure AI task service"
},
"entry_type": "AI task service",
"step": {
"set_options": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"chat_model": "[%key:common::generic::model%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
}
}
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"services": {

View File

@@ -0,0 +1,216 @@
"""Text to speech support for Google Generative AI."""
from __future__ import annotations
from contextlib import suppress
import io
import logging
from typing import Any
import wave
from google.genai import types
from homeassistant.components.tts import (
ATTR_VOICE,
TextToSpeechEntity,
TtsAudioType,
Voice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TTS entity."""
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
async_add_entities([tts_entity])
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
"""Google Generative AI text-to-speech entity."""
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
_attr_supported_languages = [
"ar-EG",
"bn-BD",
"de-DE",
"en-IN",
"en-US",
"es-US",
"fr-FR",
"hi-IN",
"id-ID",
"it-IT",
"ja-JP",
"ko-KR",
"mr-IN",
"nl-NL",
"pl-PL",
"pt-BR",
"ro-RO",
"ru-RU",
"ta-IN",
"te-IN",
"th-TH",
"tr-TR",
"uk-UA",
"vi-VN",
]
_attr_default_language = "en-US"
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
_supported_voices = [
Voice(voice.split(" ", 1)[0].lower(), voice)
for voice in (
"Zephyr (Bright)",
"Puck (Upbeat)",
"Charon (Informative)",
"Kore (Firm)",
"Fenrir (Excitable)",
"Leda (Youthful)",
"Orus (Firm)",
"Aoede (Breezy)",
"Callirrhoe (Easy-going)",
"Autonoe (Bright)",
"Enceladus (Breathy)",
"Iapetus (Clear)",
"Umbriel (Easy-going)",
"Algieba (Smooth)",
"Despina (Smooth)",
"Erinome (Clear)",
"Algenib (Gravelly)",
"Rasalgethi (Informative)",
"Laomedeia (Upbeat)",
"Achernar (Soft)",
"Alnilam (Firm)",
"Schedar (Even)",
"Gacrux (Mature)",
"Pulcherrima (Forward)",
"Achird (Friendly)",
"Zubenelgenubi (Casual)",
"Vindemiatrix (Gentle)",
"Sadachbia (Lively)",
"Sadaltager (Knowledgeable)",
"Sulafat (Warm)",
)
]
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize Google Generative AI Conversation speech entity."""
self.entry = entry
self._attr_name = "Google Generative AI TTS"
self._attr_unique_id = f"{entry.entry_id}_tts"
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
self._genai_client = entry.runtime_data
self._default_voice_id = self._supported_voices[0].voice_id
@callback
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
"""Return a list of supported voices for a language."""
return self._supported_voices
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
try:
response = self._genai_client.models.generate_content(
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
contents=message,
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name=options.get(
ATTR_VOICE, self._default_voice_id
)
)
)
),
),
)
data = response.candidates[0].content.parts[0].inline_data.data
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
except Exception as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
raise HomeAssistantError(exc) from exc
return "wav", self._convert_to_wav(data, mime_type)
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
"""Generate a WAV file header for the given audio data and parameters.
Args:
audio_data: The raw audio data as a bytes object.
mime_type: Mime type of the audio data.
Returns:
A bytes object representing the WAV file header.
"""
parameters = self._parse_audio_mime_type(mime_type)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(parameters["bits_per_sample"] // 8)
wf.setframerate(parameters["rate"])
wf.writeframes(audio_data)
return wav_buffer.getvalue()
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
"""Parse bits per sample and rate from an audio MIME type string.
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
Args:
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
Returns:
A dictionary with "bits_per_sample" and "rate" keys. Values will be
integers if found, otherwise None.
"""
if not mime_type.startswith("audio/L"):
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
bits_per_sample = 16
rate = 24000
# Extract rate from parameters
parts = mime_type.split(";")
for param in parts: # Skip the main type part
param = param.strip()
if param.lower().startswith("rate="):
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}

View File

@@ -78,86 +78,85 @@ def _read_file_contents(
return results
async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
coordinator = config_entry.runtime_data
client_api = coordinator.client
upload_tasks = []
file_results = await call.hass.async_add_executor_job(
_read_file_contents, call.hass, call.data[CONF_FILENAME]
)
album = call.data[CONF_ALBUM]
try:
album_id = await coordinator.get_or_create_album(album)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="create_album_error",
translation_placeholders={"message": str(err)},
) from err
for mime_type, content in file_results:
upload_tasks.append(client_api.upload_content(content, mime_type))
try:
upload_results = await asyncio.gather(*upload_tasks)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="upload_error",
translation_placeholders={"message": str(err)},
) from err
try:
upload_result = await client_api.create_media_items(
[
NewMediaItem(SimpleMediaItem(upload_token=upload_result.upload_token))
for upload_result in upload_results
],
album_id=album_id,
)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": str(err)},
) from err
if call.return_response:
return {
"media_items": [
{"media_item_id": item_result.media_item.id}
for item_result in upload_result.new_media_item_results
if item_result.media_item and item_result.media_item.id
],
"album_id": album_id,
}
return None
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register Google Photos services."""
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
coordinator = config_entry.runtime_data
client_api = coordinator.client
upload_tasks = []
file_results = await hass.async_add_executor_job(
_read_file_contents, hass, call.data[CONF_FILENAME]
)
album = call.data[CONF_ALBUM]
try:
album_id = await coordinator.get_or_create_album(album)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="create_album_error",
translation_placeholders={"message": str(err)},
) from err
for mime_type, content in file_results:
upload_tasks.append(client_api.upload_content(content, mime_type))
try:
upload_results = await asyncio.gather(*upload_tasks)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="upload_error",
translation_placeholders={"message": str(err)},
) from err
try:
upload_result = await client_api.create_media_items(
[
NewMediaItem(
SimpleMediaItem(upload_token=upload_result.upload_token)
)
for upload_result in upload_results
],
album_id=album_id,
)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": str(err)},
) from err
if call.return_response:
return {
"media_items": [
{"media_item_id": item_result.media_item.id}
for item_result in upload_result.new_media_item_results
if item_result.media_item and item_result.media_item.id
],
"album_id": album_id,
}
return None
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
_async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,7 @@ async def update_addon(
client = get_supervisor_client(hass)
if backup:
# pylint: disable-next=import-outside-toplevel
from .backup import backup_addon_before_update
from .backup import backup_addon_before_update # noqa: PLC0415
await backup_addon_before_update(hass, addon, addon_name, installed_version)
@@ -50,8 +49,7 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) ->
client = get_supervisor_client(hass)
if backup:
# pylint: disable-next=import-outside-toplevel
from .backup import backup_core_before_update
from .backup import backup_core_before_update # noqa: PLC0415
await backup_core_before_update(hass)
@@ -71,8 +69,7 @@ async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> N
client = get_supervisor_client(hass)
if backup:
# pylint: disable-next=import-outside-toplevel
from .backup import backup_core_before_update
from .backup import backup_core_before_update # noqa: PLC0415
await backup_core_before_update(hass)

View File

@@ -100,9 +100,11 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
try:
response = await self._api.route(
transport_mode=TransportMode(params.travel_mode),
origin=here_routing.Place(params.origin[0], params.origin[1]),
origin=here_routing.Place(
float(params.origin[0]), float(params.origin[1])
),
destination=here_routing.Place(
params.destination[0], params.destination[1]
float(params.destination[0]), float(params.destination[1])
),
routing_mode=params.route_mode,
arrival_time=params.arrival,
@@ -133,8 +135,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData:
"""Parse the routing response dict to a HERETravelTimeData."""
distance: float = 0.0
duration: float = 0.0
duration_in_traffic: float = 0.0
duration: int = 0
duration_in_traffic: int = 0
for section in response["routes"][0]["sections"]:
distance += DistanceConverter.convert(
@@ -167,8 +169,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
destination_name = names[0]["value"]
return HERETravelTimeData(
attribution=None,
duration=round(duration / 60),
duration_in_traffic=round(duration_in_traffic / 60),
duration=duration,
duration_in_traffic=duration_in_traffic,
distance=distance,
origin=f"{mapped_origin_lat},{mapped_origin_lon}",
destination=f"{mapped_destination_lat},{mapped_destination_lon}",
@@ -271,13 +273,13 @@ class HERETransitDataUpdateCoordinator(
UnitOfLength.METERS,
UnitOfLength.KILOMETERS,
)
duration: float = sum(
duration: int = sum(
section["travelSummary"]["duration"] for section in sections
)
return HERETravelTimeData(
attribution=attribution,
duration=round(duration / 60),
duration_in_traffic=round(duration / 60),
duration=duration,
duration_in_traffic=duration,
distance=distance,
origin=f"{mapped_origin_lat},{mapped_origin_lon}",
destination=f"{mapped_destination_lat},{mapped_destination_lon}",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/here_travel_time",
"iot_class": "cloud_polling",
"loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"],
"requirements": ["here-routing==1.0.1", "here-transit==1.2.1"]
"requirements": ["here-routing==1.2.0", "here-transit==1.2.1"]
}

View File

@@ -6,6 +6,8 @@ from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
from here_routing import RoutingMode
class HERETravelTimeData(TypedDict):
"""Routing information."""
@@ -27,6 +29,6 @@ class HERETravelTimeAPIParams:
destination: list[str]
origin: list[str]
travel_mode: str
route_mode: str
route_mode: RoutingMode
arrival: datetime | None
departure: datetime | None

View File

@@ -55,14 +55,18 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]
icon=ICONS.get(travel_mode, ICON_CAR),
key=ATTR_DURATION,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
),
SensorEntityDescription(
translation_key="duration_in_traffic",
icon=ICONS.get(travel_mode, ICON_CAR),
key=ATTR_DURATION_IN_TRAFFIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
),
SensorEntityDescription(
translation_key="distance",

View File

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

View File

@@ -21,6 +21,7 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.17.1"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.18.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -0,0 +1,71 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: done
comment: |
Full polling is performed at the configuration entry setup and
device polling is performed when a CONNECTED or a PAIRED event is received.
If many CONNECTED or PAIRED events are received for a device within a short time span,
the integration will stop polling for that device and will create a repair issue.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: done
comment: |
Event entities are disabled by default to prevent user confusion regarding
which events are supported by its appliance.
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: |
This integration doesn't have settings in its configuration flow.
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -309,8 +309,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Set up the options flow."""
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha.radio_manager import (
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
ZhaMultiPANMigrationHelper,
)
@@ -451,16 +450,11 @@ class OptionsFlowHandler(OptionsFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure the Silicon Labs Multiprotocol add-on."""
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha.radio_manager import (
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
ZhaMultiPANMigrationHelper,
)
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha.silabs_multiprotocol import (
from homeassistant.components.zha.silabs_multiprotocol import ( # noqa: PLC0415
async_get_channel as async_get_zha_channel,
)
@@ -747,11 +741,8 @@ class OptionsFlowHandler(OptionsFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Perform initial backup and reconfigure ZHA."""
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.zha.radio_manager import (
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
ZhaMultiPANMigrationHelper,
)

View File

@@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
entry.runtime_data = homee
entry.async_on_unload(homee.disconnect)
def _connection_update_callback(connected: bool) -> None:
async def _connection_update_callback(connected: bool) -> None:
"""Call when the device is notified of changes."""
if connected:
_LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])

View File

@@ -28,6 +28,7 @@ class HomeeEntity(Entity):
self._entry = entry
node = entry.runtime_data.get_node_by_id(attribute.node_id)
# Homee hub itself has node-id -1
assert node is not None
if node.id == -1:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.runtime_data.settings.uid)},
@@ -41,6 +42,8 @@ class HomeeEntity(Entity):
model=get_name_for_enum(NodeProfile, node.profile),
via_device=(DOMAIN, entry.runtime_data.settings.uid),
)
if attribute.name:
self._attr_name = attribute.name
self._host_connected = entry.runtime_data.connected
@@ -79,7 +82,7 @@ class HomeeEntity(Entity):
def _on_node_updated(self, attribute: HomeeAttribute) -> None:
self.schedule_update_ha_state()
def _on_connection_changed(self, connected: bool) -> None:
async def _on_connection_changed(self, connected: bool) -> None:
self._host_connected = connected
self.schedule_update_ha_state()
@@ -166,6 +169,6 @@ class HomeeNodeEntity(Entity):
def _on_node_updated(self, node: HomeeNode) -> None:
self.schedule_update_ha_state()
def _on_connection_changed(self, connected: bool) -> None:
async def _on_connection_changed(self, connected: bool) -> None:
self._host_connected = connected
self.schedule_update_ha_state()

View File

@@ -1,9 +1,13 @@
"""The homee event platform."""
from pyHomee.const import AttributeType
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -13,6 +17,38 @@ from .entity import HomeeEntity
PARALLEL_UPDATES = 0
REMOTE_PROFILES = [
NodeProfile.REMOTE,
NodeProfile.TWO_BUTTON_REMOTE,
NodeProfile.THREE_BUTTON_REMOTE,
NodeProfile.FOUR_BUTTON_REMOTE,
]
EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = {
AttributeType.BUTTON_STATE: EventEntityDescription(
key="button_state",
device_class=EventDeviceClass.BUTTON,
event_types=["upper", "lower", "released"],
),
AttributeType.UP_DOWN_REMOTE: EventEntityDescription(
key="up_down_remote",
device_class=EventDeviceClass.BUTTON,
event_types=[
"released",
"up",
"down",
"stop",
"up_long",
"down_long",
"stop_long",
"c_button",
"b_button",
"a_button",
],
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
@@ -21,30 +57,31 @@ async def async_setup_entry(
"""Add event entities for homee."""
async_add_entities(
HomeeEvent(attribute, config_entry)
HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type == AttributeType.UP_DOWN_REMOTE
if attribute.type in EVENT_DESCRIPTIONS
and node.profile in REMOTE_PROFILES
and not attribute.editable
)
class HomeeEvent(HomeeEntity, EventEntity):
"""Representation of a homee event."""
_attr_translation_key = "up_down_remote"
_attr_event_types = [
"released",
"up",
"down",
"stop",
"up_long",
"down_long",
"stop_long",
"c_button",
"b_button",
"a_button",
]
_attr_device_class = EventDeviceClass.BUTTON
def __init__(
self,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: EventEntityDescription,
) -> None:
"""Initialize the homee event entity."""
super().__init__(attribute, entry)
self.entity_description = description
self._attr_translation_key = description.key
if attribute.instance > 0:
self._attr_translation_key = f"{self._attr_translation_key}_instance"
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
async def async_added_to_hass(self) -> None:
"""Add the homee event entity to home assistant."""
@@ -56,6 +93,5 @@ class HomeeEvent(HomeeEntity, EventEntity):
@callback
def _event_triggered(self, event: HomeeAttribute) -> None:
"""Handle a homee event."""
if event.type == AttributeType.UP_DOWN_REMOTE:
self._trigger_event(self.event_types[int(event.current_value)])
self.schedule_update_ha_state()
self._trigger_event(self.event_types[int(event.current_value)])
self.schedule_update_ha_state()

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