Compare commits

..

170 Commits

Author SHA1 Message Date
Erik
df446f5240 Don't allow adding entities which don't belong to an entity platform 2025-07-01 16:27:10 +02:00
Claudio Ruggeri - CR-Tech
c92873bbff Change default slave id from 0 to 1 in modbus actions (#142865)
* set default slave id in service calls

* add test

* revert out of scope change
2025-07-01 13:15:32 +02:00
Norbert Rittel
5fea4915ef Use (new) common state "Empty" in litterrobot (#147835) 2025-07-01 13:13:12 +02:00
dependabot[bot]
8fa016059d Bump github/codeql-action from 3.29.1 to 3.29.2 (#147867)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 12:30:01 +02:00
Bob Laz
61a29db72c fix state_class for water used today sensor (#147787) 2025-07-01 12:28:13 +02:00
epenet
5a3aa7874d Use correctly formatted MAC in airthings tests (#147817) 2025-07-01 12:26:10 +02:00
Parker Brown
12e2493c42 Capitalize "version" in Tesla fleet strings (#146501) 2025-07-01 12:18:55 +02:00
Paulus Schoutsen
659cd42739 Move async_reload on updates in async_setup_entry in Anthropic (#147862)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-01 12:16:00 +02:00
Paulus Schoutsen
7fcea17e83 Move async_reload on updates in async_setup_entry in OpenAI Conversation (#147863)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-01 12:15:28 +02:00
Paulus Schoutsen
30a85c40da Move async_reload on updates in async_setup_entry in Ollama (#147861)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-01 12:14:46 +02:00
epenet
57a8f1e0cc Use correctly formatted MAC in rehlko tests (#147864) 2025-07-01 12:09:00 +02:00
epenet
78aeae577d Use correctly formatted MAC in roomba tests (#147865) 2025-07-01 11:24:08 +02:00
epenet
3f95cb37e6 Use correctly formatted MAC in sma tests (#147866) 2025-07-01 11:23:31 +02:00
epenet
12aef4aae5 Use correctly formatted MAC in knocki tests (#147821) 2025-07-01 11:22:48 +02:00
Thomas55555
2e12db001d Fix wrong state in Husqvarna Automower (#146075) 2025-07-01 10:53:55 +02:00
epenet
573325be97 Use correctly formatted MAC in home_connect tests (#147818) 2025-07-01 10:51:49 +02:00
Erik Montnemery
7021fe7495 Correct openai conversation config entry migration (#147859) 2025-07-01 10:49:07 +02:00
Erik Montnemery
b7999755bd Correct anthropic config entry migration (#147857) 2025-07-01 10:47:06 +02:00
Erik Montnemery
99f7a031d6 Correct Google generative AI config entry migration (#147856) 2025-07-01 10:46:13 +02:00
Erik Montnemery
8fc31283b7 Correct ollama config entry migration (#147858) 2025-07-01 10:45:17 +02:00
Jan-Philipp Benecke
5ff698c78d Catch access denied errors in webdav and display proper message (#147093) 2025-07-01 10:15:45 +02:00
Jesse Hills
9469c6ad1c Implement suggested_display_precision for ESPHome (#147849) 2025-07-01 09:16:23 +02:00
Norbert Rittel
35f0505c7b Use (new) common state "Empty" in whirlpool (#147847)
Use (new) common state "Empty"
2025-07-01 08:59:55 +02:00
Norbert Rittel
a180cabea9 Use (new) common state "Full" in overkiz (#147848)
Use (new) common state "Full"
2025-07-01 08:58:31 +02:00
Jan Bouwhuis
4f7348b8bc Fix invalid configuration of MQTT device QoS option in subentry flow (#147837) 2025-07-01 08:46:58 +02:00
On Freund
ddf56f053b Support device removal in CoolMasterNet integration (#147851) 2025-07-01 08:26:04 +02:00
G Johansson
9719d2ef2b Start deprecation of battery properties in vacuum (#146401)
* Start deprecation of battery properties in vacuum

* Small fixes

* Fixes

* Deprecate battery supported feature
2025-07-01 08:23:47 +02:00
Manu
2afe475234 Add more mac address prefixes for discovery to PlayStation Network (#147739) 2025-07-01 07:12:00 +02:00
Norbert Rittel
23c304fc75 Use (new) common state "Full" in enphase_envoy (#147834)
Use (new) common state "Full"
2025-06-30 20:13:05 -04:00
Norbert Rittel
84645d0ca6 Use (new) common states for "Full" and "Empty" in lg_thinq (#147833)
Use (new) common states for "Full" and "Empty"
2025-07-01 01:59:33 +02:00
Norbert Rittel
2bdfc8cf5e Add common states "Empty" and "Full" (#146646) 2025-06-30 22:08:55 +02:00
epenet
603e277a5b Add docstring to DhcpServiceInfo MAC address (#147823)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-06-30 21:54:05 +02:00
Paulus Schoutsen
38a7b21052 Split Anthropic entity (#147770) 2025-06-30 21:47:44 +02:00
Paulus Schoutsen
bf74ba990a Split Ollama entity (#147769) 2025-06-30 21:31:54 +02:00
Paulus Schoutsen
70856bd92a Split OpenAI entity (#147771) 2025-06-30 21:11:51 +02:00
Paulus Schoutsen
be6b624081 Improve validation for media selector (#147768) 2025-06-30 20:26:52 +02:00
mvn23
217fbb2849 Populate hvac_modes list in opentherm_gw (#142074) 2025-06-30 20:24:13 +02:00
epenet
22a14da19c Rename service registration method (#146615) 2025-06-30 20:21:38 +02:00
puddly
20f5d85800 Await firmware installation task when flashing ZBT-1/Yellow firmware (#147824) 2025-06-30 20:18:22 +02:00
hanwg
88feb5139b Fix Telegram bot proxy URL not initialized when creating a new bot (#147707) 2025-06-30 20:16:45 +02:00
Hessel
90cbe272a0 Wallbox Integration, Reduce API impact by limiting the amount of API calls made (#147618) 2025-06-30 20:15:48 +02:00
Paulus Schoutsen
511b739bf6 Use media selector for Assist Satellite actions (#147767)
Co-authored-by: Michael Hansen <mike@rhasspy.org>
2025-06-30 20:12:03 +02:00
Manu
9961a499ee Fix sensor displaying unknown when getting readings from heat meters in ista EcoTrend (#147741) 2025-06-30 20:11:46 +02:00
rubenbe
d8c7ed473b Bump xiaomi-ble to 1.1.0 (#147828)
Bump xiaomi-ble to 1.1.0
2025-06-30 20:11:03 +02:00
Manu
2c30a5a14c Improve exception handling of PlayStation Network (#147792) 2025-06-30 19:53:46 +02:00
Manu
5e3fc858d8 Add sensor last online to PlayStation Network integration (#147796) 2025-06-30 19:52:11 +02:00
epenet
f03af213d4 Use correctly formatted MAC in lg_thinq tests (#147822) 2025-06-30 19:50:50 +02:00
epenet
1e3ebd5650 Use correctly formatted MAC in incomfort tests (#147819) 2025-06-30 18:02:42 +02:00
epenet
53936ab062 Use async_load_fixture in weatherflow_cloud (#147816) 2025-06-30 18:01:14 +02:00
Bouwe Westerdijk
b52a248def Bump plugwise to v1.7.7 and adapt (#147809) 2025-06-30 14:40:10 +01:00
Jeef
ea70229426 Add Weatherflow Cloud wind support via websocket (#125611)
* rebase off of dev

* update tests

* update tests

* addressing PR finally

* API to back

* adding a return type

* need to test

* removed teh extra check on available

* some changes

* ready for re-review

* change assertions

* remove icon function

* update ambr

* ruff

* update snapshot and push

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* enhnaced tests

* better coverage

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* remove comments

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-30 15:26:17 +02:00
Erik Montnemery
741a3d5009 Remove backup helper (#143558)
* Remove backup helper

* Update aws_s3 tests
2025-06-30 14:11:10 +02:00
Pete Sage
ee8830cc77 Person ble_trackers for non-home zones not processed correctly (#138475)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-30 13:35:19 +02:00
Bouwe Westerdijk
7fbf25e862 Plugwise: remove outdated fixtures (#147806) 2025-06-30 12:15:52 +02:00
epenet
e642cd45ae Enforce async_load_fixture in async test functions (#145709) 2025-06-30 11:56:26 +02:00
dependabot[bot]
179e1c2b00 Bump github/codeql-action from 3.29.0 to 3.29.1 (#147799)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 11:53:30 +02:00
Phill (pssc)
52a99aea0c Squeezebox: Fix Allow server device details to merge with players with the same MAC (#133517)
* Disambiguate bewtween servers and player to stop them being merged

* ruff format

* make SqueezeLite players not a service

* ruff

* Tidy redunant code

* config url

* revert config url

* change to domain server

* use default to see how they are mereged with server device

* refactor to use defaults so where a player is part of a bigger ie server service device in the same intergration it doesnt replace its information

* ruff

* make test match the new data

* Fix merge

* Fix tests

* Fix meregd test data

* Fix all tests add new test for merged device in reg

* Remove info from device_info so its only a lookup

* manual merge of server player shared devices

* Fix format of merged entires

* fixes for testing

* Fix test with input from @peteS-UK device knowlonger exits for this test

* Fix test now device doesnt exits for tests

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix Copilots formatting

* Apply suggestions from code review

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-30 11:41:22 +02:00
Paulus Schoutsen
c7b2f236be Type Z-Wave JS config entry (#147456)
* Type Z-Wave JS config entry

* Migrate to data class
2025-06-30 11:15:12 +02:00
Evan Severson
a6e3da43ca Fixed pushbullet handling of fields longer than 255 characters (#146993) 2025-06-30 11:08:50 +02:00
Steffen Rusitschka
4d58024d5d Add publish_string_states config to zabbix (#134773)
* Add include_strings config to zabbix

* Remove commented code

* Fix ruff formatting

* Update homeassistant/components/zabbix/__init__.py

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>

* Update homeassistant/components/zabbix/__init__.py

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>

* Don't use dict.get, CONF_INCLUDE_STRINGS has a default value and will always be set.

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Convert to string only when include_strings is true

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* change to guard

* Fix review comments

* ruff, mypy, pylint fixes

* more ruff, mypy fixes

* and another ruff format fix

---------

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-30 10:52:33 +02:00
Manu
c7603b39ec Fix inputs to correctly handle Fahrenheit in IronOS (#135421)
* Fix inputs to correctly handle Fahrenheit in IronOS

* some refactoring

* add boost switch entity

* Revert switch entity

* refactor

* remove commented code

* some changes
2025-06-30 10:44:39 +02:00
epenet
c17ee0d123 Allow binary sensor template to return state unknown (#128861)
* Allow binary sensor template to return state unknown

* Add tests

* Adjust TriggerBinarySensorEntity

* Add restore tests for BinarySensorTemplate

* Add tests for TriggerBinarySensorEntity

* Tweak

* Tweak

* Adjust tests

* Adjust
2025-06-30 10:06:05 +02:00
Alexandre CUER
97c1e21a69 Add possibility to synchronize automatically all available feeds in emoncms (#128122)
* Add checkbox in options to sync all feeds once

* Add sync mode selector in async_step_user
Remove checkbox in options

* Correct use of SYNC_MODE & SYNC_MODE_AUTO in tests

* Use dropdown for mode selection

* rmv_unused_const

* Add separate tests + use SelectSelector
2025-06-30 10:05:07 +02:00
starkillerOG
c9a6b1fd45 Bump reolink_aio to 0.14.2 (#147797) 2025-06-30 09:39:02 +02:00
mkmer
05ceee568e Honeywell: Don't use shared session (#147772) 2025-06-29 21:22:59 +02:00
Shay Levy
08a6b38699 Bump aioshelly to 13.7.1 (#146221)
* Bump aioshelly to 13.8.0

* Change version to 13.7.1
2025-06-29 21:41:50 +03:00
Norbert Rittel
4add346272 Deduplicate strings and fix sentence-casing in proximity (#147777)
* Deduplicate strings and fix sentence-casing in `proximity`

* Update test_init.py
2025-06-29 21:00:16 +03:00
Andre Lengwenus
369c8d1e0d Bump pypck to 0.8.10 (#147774) 2025-06-29 20:58:41 +03:00
tronikos
25ab47a587 Move the async_reload on updates in async_setup_entry in Google Generative AI (#147748)
Move the async_reload on updates in async_setup_entry
2025-06-29 07:56:37 +02:00
Marc Hörsken
617ea1925c Update pywmspro to 0.3.0 to wait for short-lived actions (#147679)
Replace action delays with detailed action responses.
2025-06-29 07:33:44 +02:00
cdnninja
8bacab4f9c Fix Vesync set_percentage error (#147751) 2025-06-29 07:22:04 +02:00
J. Nick Koston
6d28b99344 Preserve httpx boolean behavior in REST integration after aiohttp conversion (#147738) 2025-06-28 17:24:09 -05:00
cnico
bbd1cbf5c9 Correct Chlorine unit definition in flipr integration (#147537)
* Correction of bug 145683

* constant for chlorine unit correction

* constant name correction

* Review correction
2025-06-28 22:29:24 +01:00
starkillerOG
43450d4489 Reduce idle timeout of HLS stream to conserve camera battery life (#147728)
* Reduce IDLE timeout of HLS stream to conserve camera battery life

* adjust tests
2025-06-28 22:20:47 +02:00
J. Nick Koston
f8c052e0ce Improve rest error logging (#147736)
* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* Improve rest error logging

* top level
2025-06-28 22:18:46 +02:00
Florian von Garrel
1f3bdfc7b7 bump pypaperless to 4.1.1 (#147735) 2025-06-28 22:13:51 +02:00
Antoni Czaplicki
0652bffd68 Bump vulcan-api to 2.4.2 (#146857) 2025-06-28 22:11:59 +02:00
Manu
8322611099 Use test parametrization in ista EcoTrend integration (#147729) 2025-06-28 21:57:51 +02:00
Marc Hörsken
134967b817 Fix error if cover position is not available or unknown (#147732) 2025-06-28 21:57:26 +02:00
Shay Levy
39abae36f0 Fix Shelly Block entity removal (#147694) 2025-06-28 22:40:58 +03:00
Marc Mueller
227760f203 Fix RuntimeWarnings in homeassistant_yellow tests (#147724) 2025-06-28 20:31:01 +02:00
Jan Bouwhuis
969809456e Move MQTT device sw and hw version to collapsed section in subentry flow (#147685)
Move MQTT device sw and hw version to collapsed section
2025-06-28 11:25:59 +02:00
Daniel Hjelseth Høyer
d2e8a48b2c Bump pytibber to 0.31.6 (#147703) 2025-06-28 10:11:17 +02:00
epenet
ea6332ee42 Move backup services to separate module (#146427) 2025-06-27 20:54:56 +02:00
Erik Montnemery
91c3b43d7f Improve comment for helpers.entity.entity_sources (#146529) 2025-06-27 20:54:19 +02:00
Thomas55555
1d82d44794 Add device prefix to summary in Husqvarna Automower (#147405) 2025-06-27 20:34:50 +02:00
Thomas55555
571376badc Bump aioautomower to 1.0.1 (#147683) 2025-06-27 20:28:45 +02:00
Manu
32236b2f4d Add reconfiguration flow to PlayStation Network (#147552) 2025-06-27 20:17:06 +02:00
Samuel Xiao
18c1953bc5 Add lock models to switchbot cloud (#147569) 2025-06-27 20:16:21 +02:00
Bernardus Jansen
d874c28dc9 Add previously missing state classes to dsmr sensors (#147633) 2025-06-27 19:45:36 +02:00
Brett Adams
19d89c8952 Fix energy history in Teslemetry (#147646) 2025-06-27 19:43:03 +02:00
Ludovic BOUÉ
e3ba1f34ca Matter TemperatureControl (#145706)
* TemperatureControl

* Add tests

* Commands.SetTemperature

* Update homeassistant/components/matter/number.py

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

* Apply suggestions from code review

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

* Update number.py

* Update number.py

* Update number.py

* Update homeassistant/components/matter/number.py

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

* Refactor MatterRangeNumber to streamline command handling in async_set_native_value

* testing requested changes

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-27 19:41:39 +02:00
Thomas55555
b630fb0520 Respect availability of parent class in Husqvarna Automower (#147649) 2025-06-27 19:38:42 +02:00
Ville Skyttä
5129f89086 Finish config flow in huawei_lte SSDP test (#147542) 2025-06-27 19:00:01 +02:00
Ville Skyttä
0be0e22e76 Simplify rflink dimmable set_level parsing (#147636) 2025-06-27 18:59:10 +02:00
epenet
b8500b338a Improve tests for binary sensor template (#147657) 2025-06-27 18:58:16 +02:00
Simone Chemelli
4cab3a0465 Bump aioamazondevices to 3.1.22 (#147681) 2025-06-27 18:44:01 +02:00
hanwg
ff711324d5 Add codeowner for Telegram bot (#147680) 2025-06-27 18:18:01 +02:00
Michael
113e7dc003 Add data descriptions to PEGELONLINE integration (#147594) 2025-06-27 18:16:38 +02:00
Shay Levy
2120ff6a0a Fix Shelly entity removal (#147665) 2025-06-27 18:50:35 +03:00
Marc Mueller
8ee5c30754 Update ruff to 0.12.1 (#147677) 2025-06-27 17:40:08 +02:00
Paul Bottein
a1518b96c4 Update frontend to 20250627.0 (#147668) 2025-06-27 17:28:14 +02:00
Petar Petrov
bba7f5c3f0 Z-WaveJS config flow: Change keys question (#147518)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 17:27:43 +02:00
Manu
8a5671af76 Remove dweet.io integration (#147645) 2025-06-27 17:23:42 +02:00
Raphael Hehl
8a18dea8c7 UniFi Protect removing early access checks and issue creation (#147432)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-27 17:15:34 +02:00
Thomas55555
4b02f22724 Bump aioautomower to 1.0.0 (#147676) 2025-06-27 17:02:52 +02:00
mkmer
7229c2ca2c Bump aiosomecomfort to 0.0.33 (#147673) 2025-06-27 16:32:25 +02:00
Norbert Rittel
d83eddf13b Fix sentence-casing and spacing of button in thermopro (#147671) 2025-06-27 15:53:18 +02:00
Josef Zweck
4a192a7b09 Bump jellyfin-apiclient-python to 1.11.0 (#147658) 2025-06-27 11:07:14 +02:00
Josef Zweck
58c434887e Fix: Unhandled NoneType sessions in jellyfin (#147659) 2025-06-27 11:00:23 +02:00
Abílio Costa
78c2405e61 Bump whirlpool to 0.21.1 (#147611) 2025-06-27 10:33:49 +02:00
Josef Zweck
8cc4105984 Make jellyfin not single config entry (#147656) 2025-06-27 10:31:13 +02:00
Josef Zweck
917f1e4c6f Make entities unavailable when machine is physically off in lamarzocco (#147426) 2025-06-27 10:03:14 +02:00
hanwg
3879f6d2ef Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) 2025-06-27 10:03:03 +02:00
Norbert Rittel
78060e4833 Clarify descriptions of subaru.unlock_specific_door action (#147655) 2025-06-27 10:01:44 +02:00
Guido Schmitz
fda66c4be4 Handle deleted devices dynamically in devolo Home Control (#147585) 2025-06-27 09:52:00 +02:00
Michael
21131d00b3 Fix config schema to make credentials optional in NUT flows (#147593) 2025-06-27 09:51:28 +02:00
puddly
a84313de33 Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-27 09:50:45 +02:00
Manu
c73346e6b3 Bump pynecil to v4.1.1 (#147648) 2025-06-27 09:31:35 +02:00
Franck Nijhof
55a37a2936 Extend GitHub Copilot instructions with new learnings from reviews (#147652) 2025-06-27 09:01:09 +02:00
Abílio Costa
e481f14335 Simplify reolink light tests (#147637) 2025-06-27 08:58:09 +02:00
Petar Petrov
1ca03c8ae9 Do not factory reset old Z-Wave controller during migration (#147576)
* Do not factory reset old Z-Wave controller during migration

* PR comments

* remove obsolete test
2025-06-27 08:02:12 +02:00
Ville Skyttä
61b43ca1fc Remove unnecessary wilight trigger regex use (#147638) 2025-06-26 23:16:21 +01:00
Joost Lekkerkerker
1b2be083c2 Make sure Google Generative AI integration migration is clean (#147625) 2025-06-26 23:03:36 +02:00
Joost Lekkerkerker
4bdf3d6f30 Make sure OpenAI integration migration is clean (#147627) 2025-06-26 23:03:11 +02:00
Joost Lekkerkerker
43535ede8b Make sure Anthropic integration migration is clean (#147629) 2025-06-26 23:02:59 +02:00
Joost Lekkerkerker
9bd0762799 Make sure Ollama integration migration is clean (#147630) 2025-06-26 23:02:35 +02:00
Ville Skyttä
1bb653b4f7 Remove unused config regexps (#147631) 2025-06-26 23:02:14 +02:00
Franck Nijhof
2655edcfc8 Extend GitHub Copilot instructions and make it suitable for Claude Code (#147632) 2025-06-26 23:00:02 +02:00
Franck Nijhof
7a08edc3dd Add Claude to gitignore (#147622) 2025-06-26 21:06:34 +02:00
Abílio Costa
b3131355b0 Use non-autospec mock for Reolink's light tests (#147621) 2025-06-26 21:05:23 +02:00
Abílio Costa
06d04c001d Use non-autospec mock for Reolink's host tests (#147619) 2025-06-26 20:55:46 +02:00
Jack Powell
babecdf32c Add Diagnostics to PlayStation Network (#147607)
* Add Diagnostics support to PlayStation_Network

* Remove unused constant

* minor cleanup

* Redact additional data

* Redact additional data
2025-06-26 20:52:07 +02:00
Renat Sibgatulin
17cd39748b Create a new client session for air-Q to fix cookie polution (#147027) 2025-06-26 19:59:49 +02:00
Simone Chemelli
c2f1e86a4e Add action exceptions to Alexa Devices (#147546) 2025-06-26 19:59:02 +02:00
Joost Lekkerkerker
61a32466b6 Hide Telegram bot proxy URL behind section (#147613)
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2025-06-26 19:55:38 +02:00
Manu
aef08091f8 Fix asset url in Habitica integration (#147612) 2025-06-26 19:52:58 +02:00
Joost Lekkerkerker
1416f0f1e0 Fix meaters not being added after a reload (#147614) 2025-06-26 19:52:29 +02:00
HarvsG
af7b1a76bc Add description placeholders to SchemaFlowFormStep (#147544)
* test description placeholders

* Update test_schema_config_entry_flow.py

* fix copy and paste indentation

* Apply suggestions from code review

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 19:51:31 +02:00
Maximilian Arzberger
bf88fcd5bf Add Manual Charge Switch for Installers for Kostal Plenticore (#146932)
* Add Manual Charge Switch for Installers

* Update stale docstring

* Installer config fixture

* fix ruff
2025-06-26 19:50:27 +02:00
Joost Lekkerkerker
35478e3162 Set Google AI model as device model (#147582)
* Set Google AI model as device model

* fix
2025-06-26 19:44:15 +02:00
Joost Lekkerkerker
69af74a593 Improve explanation on how to get API token in Telegram (#147605) 2025-06-26 18:21:56 +02:00
tronikos
b4dd912bee Refactor in Google AI TTS in preparation for STT (#147562) 2025-06-26 11:53:16 -04:00
Bram Kragten
b5821ef499 Update frontend to 20250626.0 (#147601) 2025-06-26 17:46:45 +02:00
Fabio Natanael Kepler
1a92d4530e Fix playing TTS and local media source over DLNA (#134903)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 17:12:15 +02:00
Joost Lekkerkerker
7b80c1c693 Add default conversation name for OpenAI integration (#147597) 2025-06-26 17:11:48 +02:00
Joost Lekkerkerker
e7cc03c1d9 Add default title to migrated Claude entry (#147598) 2025-06-26 17:11:13 +02:00
Luca Angemi
69f0b6244a Remove default icon for wind direction sensor for Buienradar (#147603)
* Fix wind direction state class sensor

* Remove default icon for wind direction sensor
2025-06-26 17:05:59 +02:00
Joost Lekkerkerker
01205f8a14 Add default title to migrated Ollama entry (#147599) 2025-06-26 17:05:26 +02:00
hanwg
68924d23ab Fix Telegram bot default target when sending messages (#147470)
* handle targets

* updated error message

* validate chat id for single target

* add validation for chat id

* handle empty target

* handle empty target
2025-06-26 16:43:09 +02:00
Artur Pragacz
40f553a007 Migrate device connections to a normalized form (#140383)
* Normalize device connections migration

* Update version

* Slightly improve tests

* Update homeassistant/helpers/device_registry.py

* Add validators

* Fix validator

* Move format mac function too

* Add validator test

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-06-26 15:33:34 +02:00
Robin Lintermann
bc46894b74 Fixed issue when tests (should) fail in Smarla (#146102)
* Fixed issue when tests (should) fail

* Use usefixture decorator

* Throw ConfigEntryError instead of AuthFailed
2025-06-26 15:30:03 +02:00
Anders Peter Fugmann
6f4615f012 Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) 2025-06-26 12:56:46 +02:00
Joost Lekkerkerker
4244d2f66f Set right model in OpenAI conversation (#147575) 2025-06-26 12:49:33 +02:00
Petar Petrov
a73dafe097 Hide unnamed paths when selecting a USB Z-Wave adapter (#147571)
* Hide unnamed paths when selecting a USB Z-Wave adapter

* remove pointless sorting
2025-06-26 12:15:02 +02:00
Stefan Agner
be49296547 Deduplicate shared logic in Matter vacuum commands (#147578)
Get the run mode by tag in a single place to avoid code duplication.
Also raise an error if the run mode (unexpectedly) is not found.
2025-06-26 11:54:52 +02:00
Marcel van der Veldt
d55ecd885e Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) 2025-06-26 11:49:06 +02:00
Luca Angemi
076248c455 Fix wind direction state class sensor for AEMET (#147535) 2025-06-26 11:07:07 +02:00
Petar Petrov
13ce27c94c Remove obsolete routing info when migrating a Z-Wave network (#147568) 2025-06-26 11:06:36 +02:00
Joost Lekkerkerker
4b9b08ece5 Show current Lametric version if there is no newer version (#147538) 2025-06-26 10:55:31 +02:00
Simone Chemelli
79df38eff2 Improve config flow strings for Alexa Devices (#147523) 2025-06-26 10:52:14 +02:00
tronikos
fb133664e4 Include subentries in Google Generative AI diagnostics (#147558) 2025-06-26 10:50:47 +02:00
Marcel van der Veldt
38669ce96c Fix sending commands to Matter vacuum (#147567) 2025-06-26 10:47:24 +02:00
Petar Petrov
651b33d49b Bump zwave-js-server-python to 0.65.0 (#147561)
* Bump zwave-js-server-python to 0.65.0

* update tests
2025-06-26 10:11:25 +03:00
Erik Montnemery
3b64db5f76 Set end date for when allowing unique id collisions in config entries (#147516)
* Set end date for when allowing unique id collisions in config entries

* Update test
2025-06-26 08:20:26 +02:00
tronikos
0f95fe566c Use default title for migrated Google Generative AI entries (#147551) 2025-06-25 22:30:41 -04:00
Simone Chemelli
6290facffb Fix unload for Alexa Devices (#147548) 2025-06-26 01:55:58 +02:00
tronikos
f0a78aadbe Fixes in Google AI TTS (#147501)
* Fix Google AI not using correct config options after subentries migration

* Fixes in Google AI TTS

* Fix tests by @IvanLH

* Change type name.

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2025-06-25 18:12:23 -04:00
Pete Sage
345ec97dd5 Add enum sensor for Sonos Power Source (#147449)
* feat: add power source sensor

* fix: translations

* fix:cleanup

* fix: simpify

* fix: improve coverage

* fix: improve coverage

* fix: add missing test

* fix: call it charging_base

* fix: disable entity by default

* update snapshots

* Update homeassistant/components/sonos/strings.json

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

* fix: update test

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-06-25 23:49:06 +02:00
Franck Nijhof
1286b5d9d8 Bump version to 2025.8.0dev0 (#147531) 2025-06-25 21:38:35 +02:00
305 changed files with 7712 additions and 6039 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.7"
HA_SHORT_VERSION: "2025.8"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.0
uses: github/codeql-action/init@v3.29.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.0
uses: github/codeql-action/analyze@v3.29.2
with:
category: "/language:python"

6
.gitignore vendored
View File

@@ -137,4 +137,8 @@ tmp_cache
.ropeproject
# Will be created from script/split_tests.py
pytest_buckets.txt
pytest_buckets.txt
# AI tooling
.claude

View File

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

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -75,7 +75,6 @@ from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError
from .helpers import (
area_registry,
backup,
category_registry,
config_validation as cv,
device_registry,
@@ -607,7 +606,7 @@ async def async_enable_logging(
)
threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception",
exc_info=( # type: ignore[arg-type] # noqa: LOG014
exc_info=( # type: ignore[arg-type]
args.exc_type,
args.exc_value,
args.exc_traceback,
@@ -880,10 +879,6 @@ async def _async_set_up_integrations(
if "recorder" in all_domains:
recorder.async_initialize_recorder(hass)
# Initialize backup
if "backup" in all_domains:
backup.async_initialize_backup(hass)
stages: list[tuple[str, set[str], int | None]] = [
*(
(name, domain_group, timeout)
@@ -1061,5 +1056,5 @@ async def _async_setup_multi_components(
_LOGGER.error(
"Error setting up integration %s - received exception",
domain,
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
exc_info=(type(result), result, result.__traceback__),
)

View File

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

View File

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

View File

@@ -33,7 +33,6 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},

View File

@@ -61,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
@@ -69,6 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""

View File

@@ -1,69 +1,17 @@
"""Conversation support for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any, Literal, cast
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
Usage,
)
from voluptuous_openapi import convert
from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.config_entries import 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 . import AnthropicConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
from .const import CONF_PROMPT, DOMAIN
from .entity import AnthropicBaseLLMEntity
async def async_setup_entry(
@@ -82,253 +30,10 @@ async def async_setup_entry(
)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=[tool_result_block],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
tool_result_block,
]
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=content.content,
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
TextBlockParam(type="text", text=content.content),
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
TextBlockParam(type="text", text=content.content)
)
elif isinstance(content, conversation.AssistantContent):
# Combine consequent assistant messages
if not messages or messages[-1]["role"] != "assistant":
messages.append(
MessageParam(
role="assistant",
content=[],
)
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in content.tool_calls
]
)
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- ...
- RawContentBlockDeltaEvent with a SignatureDelta
- RawContentBlockStopEvent
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
- RawContentBlockStartEvent with an empty TextBlock
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- ...
- RawContentBlockStopEvent
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
- RawContentBlockDeltaEvent with a InputJSONDelta
- RawContentBlockDeltaEvent with a InputJSONDelta
- ...
- RawContentBlockStopEvent
- RawMessageDeltaEvent with a stop_reason='tool_use'
- RawMessageStopEvent(type='message_stop')
Each message could contain multiple blocks of the same type.
"""
if result is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
input_usage: Usage | None = None
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_block["id"],
tool_name=current_block["name"],
tool_args=tool_args,
)
]
}
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
conversation.ConversationEntity,
conversation.AbstractConversationAgent,
AnthropicBaseLLMEntity,
):
"""Anthropic conversation agent."""
@@ -336,17 +41,7 @@ class AnthropicConversationEntity(
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
)
super().__init__(entry, subentry)
if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
@@ -357,13 +52,6 @@ class AnthropicConversationEntity(
"""Return a list of supported languages."""
return MATCH_ALL
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
@@ -394,77 +82,3 @@ class AnthropicConversationEntity(
conversation_id=chat_log.conversation_id,
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.subentry.data
tools: list[ToolParam] | 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
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,393 @@
"""Base entity for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any, cast
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
Usage,
)
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from . import AnthropicConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=[tool_result_block],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
tool_result_block,
]
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=content.content,
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
TextBlockParam(type="text", text=content.content),
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
TextBlockParam(type="text", text=content.content)
)
elif isinstance(content, conversation.AssistantContent):
# Combine consequent assistant messages
if not messages or messages[-1]["role"] != "assistant":
messages.append(
MessageParam(
role="assistant",
content=[],
)
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in content.tool_calls
]
)
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- ...
- RawContentBlockDeltaEvent with a SignatureDelta
- RawContentBlockStopEvent
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
- RawContentBlockStartEvent with an empty TextBlock
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- ...
- RawContentBlockStopEvent
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
- RawContentBlockDeltaEvent with a InputJSONDelta
- RawContentBlockDeltaEvent with a InputJSONDelta
- ...
- RawContentBlockStopEvent
- RawMessageDeltaEvent with a stop_reason='tool_use'
- RawMessageStopEvent(type='message_stop')
Each message could contain multiple blocks of the same type.
"""
if result is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
input_usage: Usage | None = None
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_block["id"],
tool_name=current_block["name"],
tool_args=tool_args,
)
]
}
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicBaseLLMEntity(Entity):
"""Anthropic base LLM entity."""
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model="Claude",
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[ToolParam] | 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
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break

View File

@@ -2,9 +2,9 @@
from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@@ -37,7 +37,6 @@ from .manager import (
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
ManagerStateEvent,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
@@ -45,6 +44,7 @@ from .manager import (
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .services import async_setup_services
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
@@ -71,12 +71,12 @@ __all__ = [
"IncorrectPasswordError",
"LocalBackupAgent",
"ManagerBackup",
"ManagerStateEvent",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
]
@@ -103,39 +103,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
backup_manager = BackupManager(hass, reader_writer)
hass.data[DATA_MANAGER] = backup_manager
try:
await backup_manager.async_setup()
except Exception as err:
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
raise
else:
hass.data[DATA_BACKUP].manager_ready.set_result(None)
await backup_manager.async_setup()
async_register_websocket_handlers(hass, with_hassio)
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
agent_id = list(backup_manager.local_backup_agents)[0]
await backup_manager.async_create_backup(
agent_ids=[agent_id],
include_addons=None,
include_all_addons=False,
include_database=True,
include_folders=None,
include_homeassistant=True,
name=None,
password=None,
)
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
await backup_manager.async_create_automatic_backup()
if not with_hassio:
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", async_handle_create_automatic_service
)
async_setup_services(hass)
async_register_http_views(hass)
@@ -164,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@callback
def async_get_manager(hass: HomeAssistant) -> BackupManager:
"""Get the backup manager instance.
Raises HomeAssistantError if the backup integration is not available.
"""
if DATA_MANAGER not in hass.data:
raise HomeAssistantError("Backup integration is not available")
return hass.data[DATA_MANAGER]

View File

@@ -1,38 +0,0 @@
"""Websocket commands for the Backup integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import async_subscribe_events
from .const import DATA_MANAGER
from .manager import ManagerStateEvent
@callback
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
"""Register websocket commands."""
websocket_api.async_register_command(hass, handle_subscribe_events)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
@websocket_api.async_response
async def handle_subscribe_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to backup events."""
def on_event(event: ManagerStateEvent) -> None:
connection.send_message(websocket_api.event_message(msg["id"], event))
if DATA_MANAGER in hass.data:
manager = hass.data[DATA_MANAGER]
on_event(manager.last_event)
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
connection.send_result(msg["id"])

View File

@@ -8,10 +8,6 @@ from datetime import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import (
async_subscribe_events,
async_subscribe_platform_events,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER
@@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
update_interval=None,
)
self.unsubscribe: list[Callable[[], None]] = [
async_subscribe_events(hass, self._on_event),
async_subscribe_platform_events(hass, self._on_event),
backup_manager.async_subscribe_events(self._on_event),
backup_manager.async_subscribe_platform_events(self._on_event),
]
self.backup_manager = backup_manager

View File

@@ -36,7 +36,6 @@ from homeassistant.helpers import (
issue_registry as ir,
start,
)
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
@@ -372,12 +371,10 @@ class BackupManager:
# Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = BlockedEvent()
self.last_action_event: ManagerStateEvent | None = None
self._backup_event_subscriptions = hass.data[
DATA_BACKUP
].backup_event_subscriptions
self._backup_platform_event_subscriptions = hass.data[
DATA_BACKUP
].backup_platform_event_subscriptions
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
self._backup_platform_event_subscriptions: list[
Callable[[BackupPlatformEvent], None]
] = []
async def async_setup(self) -> None:
"""Set up the backup manager."""
@@ -1385,6 +1382,32 @@ class BackupManager:
for subscription in self._backup_event_subscriptions:
subscription(event)
@callback
def async_subscribe_events(
self,
on_event: Callable[[ManagerStateEvent], None],
) -> Callable[[], None]:
"""Subscribe events."""
def remove_subscription() -> None:
self._backup_event_subscriptions.remove(on_event)
self._backup_event_subscriptions.append(on_event)
return remove_subscription
@callback
def async_subscribe_platform_events(
self,
on_event: Callable[[BackupPlatformEvent], None],
) -> Callable[[], None]:
"""Subscribe to backup platform events."""
def remove_subscription() -> None:
self._backup_platform_event_subscriptions.remove(on_event)
self._backup_platform_event_subscriptions.append(on_event)
return remove_subscription
def _create_automatic_backup_failed_issue(
self, translation_key: str, translation_placeholders: dict[str, str] | None
) -> None:

View File

@@ -19,9 +19,14 @@ from homeassistant.components.onboarding import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
from . import (
BackupManager,
Folder,
IncorrectPasswordError,
async_get_manager,
http as backup_http,
)
if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData
@@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
if self._data["done"]:
raise HTTPUnauthorized
manager = await async_get_backup_manager(request.app[KEY_HASS])
manager = async_get_manager(request.app[KEY_HASS])
return await func(self, manager, request, *args, **kwargs)
return with_backup

View File

@@ -0,0 +1,36 @@
"""The Backup integration."""
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.hassio import is_hassio
from .const import DATA_MANAGER, DOMAIN
async def _async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
backup_manager = call.hass.data[DATA_MANAGER]
agent_id = list(backup_manager.local_backup_agents)[0]
await backup_manager.async_create_backup(
agent_ids=[agent_id],
include_addons=None,
include_all_addons=False,
include_database=True,
include_folders=None,
include_homeassistant=True,
name=None,
password=None,
)
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services."""
if not is_hassio(hass):
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", _async_handle_create_automatic_service
)

View File

@@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv
from .config import Day, ScheduleRecurrence
from .const import DATA_MANAGER, LOGGER
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
from .manager import (
DecryptOnDowloadNotSupported,
IncorrectPasswordError,
ManagerStateEvent,
)
from .models import BackupNotFound, Folder
@@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
websocket_api.async_register_command(hass, handle_delete)
websocket_api.async_register_command(hass, handle_restore)
websocket_api.async_register_command(hass, handle_subscribe_events)
websocket_api.async_register_command(hass, handle_config_info)
websocket_api.async_register_command(hass, handle_config_update)
@@ -417,3 +422,22 @@ def handle_config_update(
changes.pop("type")
manager.config.update(**changes)
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
@websocket_api.async_response
async def handle_subscribe_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to backup events."""
def on_event(event: ManagerStateEvent) -> None:
connection.send_message(websocket_api.event_message(msg["id"], event))
manager = hass.data[DATA_MANAGER]
on_event(manager.last_event)
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
connection.send_result(msg["id"])

View File

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

View File

@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.typing import ConfigType
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
from .services import setup_services
from .services import async_setup_services
from .types import BoschAlarmConfigEntry
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up bosch alarm services."""
setup_services(hass)
async_setup_services(hass)
return True

View File

@@ -9,7 +9,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
@@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None:
) from err
def setup_services(hass: HomeAssistant) -> None:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the bosch alarm integration."""
hass.services.async_register(

View File

@@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_SWING_SUPPORT
from .const import CONF_SWING_SUPPORT, DOMAIN
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
@@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
"""Unload a Coolmaster config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return not device_entry.identifiers.intersection(
(DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data
)

View File

@@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
@@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity):
) # This is not doing I/O. It fetches an internal state of the API
self._attr_should_poll = False
self._attr_unique_id = element_uid
self._attr_device_info = DeviceInfo(
self._attr_device_info = dr.DeviceInfo(
configuration_url=f"https://{urlparse(device_instance.href).netloc}",
identifiers={(DOMAIN, self._device_instance.uid)},
manufacturer=device_instance.brand,
@@ -88,6 +88,16 @@ class DevoloDeviceEntity(Entity):
elif len(message) == 3 and message[2] == "status":
# Maybe the API wants to tell us, that the device went on- or offline.
self._attr_available = self._device_instance.is_online()
elif message[1] == "del" and self.platform.config_entry:
device_registry = dr.async_get(self.hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, self._device_instance.uid)}
)
if device:
device_registry.async_update_device(
device.id,
remove_config_entry_id=self.platform.config_entry.entry_id,
)
else:
_LOGGER.debug("No valid message received: %s", message)

View File

@@ -12,7 +12,7 @@ from .bridge import DynaliteBridge
from .const import DOMAIN, LOGGER, PLATFORMS
from .convert_config import convert_config
from .panel import async_register_dynalite_frontend
from .services import setup_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform."""
setup_services(hass)
async_setup_services(hass)
await async_register_dynalite_frontend(hass)

View File

@@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None:
@callback
def setup_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Dynalite platform."""
hass.services.async_register(
DOMAIN,

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.5.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
}

View File

@@ -16,7 +16,12 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
selector,
)
from .const import (
CONF_MESSAGE,
@@ -26,6 +31,9 @@ from .const import (
FEED_ID,
FEED_NAME,
FEED_TAG,
SYNC_MODE,
SYNC_MODE_AUTO,
SYNC_MODE_MANUAL,
)
@@ -102,6 +110,17 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
"mode": "dropdown",
"multiple": True,
}
if user_input.get(SYNC_MODE) == SYNC_MODE_AUTO:
return self.async_create_entry(
title=sensor_name(self.url),
data={
CONF_URL: self.url,
CONF_API_KEY: self.api_key,
CONF_ONLY_INCLUDE_FEEDID: [
feed[FEED_ID] for feed in result[CONF_MESSAGE]
],
},
)
return await self.async_step_choose_feeds()
return self.async_show_form(
step_id="user",
@@ -110,6 +129,15 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
vol.Required(
SYNC_MODE, default=SYNC_MODE_MANUAL
): SelectSelector(
SelectSelectorConfig(
options=[SYNC_MODE_MANUAL, SYNC_MODE_AUTO],
mode=SelectSelectorMode.DROPDOWN,
translation_key=SYNC_MODE,
)
),
}
),
user_input,

View File

@@ -14,6 +14,9 @@ EMONCMS_UUID_DOC_URL = (
FEED_ID = "id"
FEED_NAME = "name"
FEED_TAG = "tag"
SYNC_MODE = "sync_mode"
SYNC_MODE_AUTO = "auto"
SYNC_MODE_MANUAL = "manual"
LOGGER = logging.getLogger(__package__)

View File

@@ -7,7 +7,8 @@
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]",
"sync_mode": "Synchronization mode"
},
"data_description": {
"url": "Server URL starting with the protocol (http or https)",
@@ -24,6 +25,14 @@
"already_configured": "This server is already configured"
}
},
"selector": {
"sync_mode": {
"options": {
"auto": "Synchronize all available Feeds",
"manual": "Select which Feeds to synchronize"
}
}
},
"entity": {
"sensor": {
"energy": {

View File

@@ -363,7 +363,7 @@
"discharging": "[%key:common::state::discharging%]",
"idle": "[%key:common::state::idle%]",
"charging": "[%key:common::state::charging%]",
"full": "Full"
"full": "[%key:common::state::full%]"
}
},
"acb_available_energy": {

View File

@@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool = False
_has_state: bool
unique_id: str
def __init__(

View File

@@ -19,7 +19,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="chlorine",
translation_key="chlorine",
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
native_unit_of_measurement="mg/L",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(

View File

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

View File

@@ -330,14 +330,13 @@ async def google_generative_ai_config_option_schema(
api_models = [api_model async for api_model in api_models_pager]
models = [
SelectOptionDict(
label=api_model.name.lstrip("models/"),
label=api_model.display_name,
value=api_model.name,
)
for api_model in sorted(
api_models, key=lambda x: x.name.lstrip("models/") or ""
)
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
if (
api_model.name
api_model.display_name
and api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name
and api_model.supported_actions

View File

@@ -27,7 +27,7 @@ from .const import (
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
)
from .coordinator import GuardianDataUpdateCoordinator
from .services import setup_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -55,7 +55,7 @@ class GuardianData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Elexa Guardian component."""
setup_services(hass)
async_setup_services(hass)
return True

View File

@@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None:
)
def setup_services(hass: HomeAssistant) -> None:
"""Register the Renault services."""
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the guardian services."""
for service_name, schema, method in (
(
SERVICE_NAME_PAIR_SENSOR,

View File

@@ -48,13 +48,13 @@ from homeassistant.components.backup import (
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
async_get_manager as async_get_backup_manager,
suggested_filename as suggested_backup_filename,
suggested_filename_from_name_date,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
@@ -839,7 +839,7 @@ async def backup_addon_before_update(
async def backup_core_before_update(hass: HomeAssistant) -> None:
"""Prepare for updating core."""
backup_manager = await async_get_backup_manager(hass)
backup_manager = async_get_backup_manager(hass)
client = get_supervisor_client(hass)
try:

View File

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

View File

@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from . import services
from .const import DOMAIN
from .coordinator import HeosConfigEntry, HeosCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.MEDIA_PLAYER]
@@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HEOS component."""
services.register(hass)
async_setup_services(hass)
return True

View File

@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
@@ -44,7 +44,8 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
def register(hass: HomeAssistant) -> None:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register HEOS services."""
hass.services.async_register(
DOMAIN,

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .services import register_actions
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -43,7 +43,7 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
register_actions(hass)
async_setup_services(hass)
return True

View File

@@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None:
await _async_service_program(call, True)
def register_actions(hass: HomeAssistant) -> None:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register custom actions."""
hass.services.async_register(

View File

@@ -2,15 +2,18 @@
from datetime import datetime
import logging
from typing import TYPE_CHECKING
from aioautomower.model import make_name_string
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
@@ -51,6 +54,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
self._attr_unique_id = mower_id
self._event: CalendarEvent | None = None
@property
def device_name(self) -> str:
"""Return the prefix for the event summary."""
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, self.mower_id)}
)
if TYPE_CHECKING:
assert device_entry is not None
assert device_entry.name is not None
return device_entry.name_by_user or device_entry.name
@property
def event(self) -> CalendarEvent | None:
"""Return the current or next upcoming event."""
@@ -66,7 +82,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
program_event.work_area_id
]
return CalendarEvent(
summary=make_name_string(work_area_name, program_event.schedule_no),
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
start=program_event.start,
end=program_event.end,
rrule=program_event.rrule_str,
@@ -93,7 +109,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
]
calendar_events.append(
CalendarEvent(
summary=make_name_string(work_area_name, program_event.schedule_no),
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
start=program_event.start.replace(tzinfo=start_date.tzinfo),
end=program_event.end.replace(tzinfo=start_date.tzinfo),
rrule=program_event.rrule_str,

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2025.6.0"]
"requirements": ["aioautomower==1.0.1"]
}

View File

@@ -10,4 +10,8 @@ OHM = "Ω"
DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533"
MAX_TEMP: int = 450
MAX_TEMP_F: int = 850
MIN_TEMP: int = 10
MIN_TEMP_F: int = 50
MIN_BOOST_TEMP: int = 250
MIN_BOOST_TEMP_F: int = 480

View File

@@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
if self.device.is_connected and characteristics:
try:
return await self.device.get_settings(list(characteristics))
return await self.device.get_settings(
list(characteristics | {CharSetting.TEMP_UNIT})
)
except CommunicationError as e:
_LOGGER.debug("Failed to fetch settings", exc_info=e)

View File

@@ -6,10 +6,9 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
@@ -24,9 +23,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from . import IronOSConfigEntry
from .const import MAX_TEMP, MIN_TEMP
from .const import (
MAX_TEMP,
MAX_TEMP_F,
MIN_BOOST_TEMP,
MIN_BOOST_TEMP_F,
MIN_TEMP,
MIN_TEMP_F,
)
from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
@@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription):
"""Describes IronOS number entity."""
value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
characteristic: CharSetting
raw_value_fn: Callable[[float], float | int] | None = None
native_max_value_f: float | None = None
native_min_value_f: float | None = None
class PinecilNumber(StrEnum):
@@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None:
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
IronOSNumberEntityDescription(
key=PinecilNumber.SETPOINT_TEMP,
translation_key=PinecilNumber.SETPOINT_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
value_fn=lambda data, _: data.setpoint_temp,
characteristic=CharSetting.SETPOINT_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_step=5,
max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP),
),
IronOSNumberEntityDescription(
key=PinecilNumber.SLEEP_TEMP,
translation_key=PinecilNumber.SLEEP_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
value_fn=lambda _, settings: settings.get("sleep_temp"),
characteristic=CharSetting.SLEEP_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_max_value=MAX_TEMP,
native_step=10,
entity_category=EntityCategory.CONFIG,
),
IronOSNumberEntityDescription(
key=PinecilNumber.BOOST_TEMP,
translation_key=PinecilNumber.BOOST_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
value_fn=lambda _, settings: settings.get("boost_temp"),
characteristic=CharSetting.BOOST_TEMP,
mode=NumberMode.BOX,
native_min_value=0,
native_max_value=MAX_TEMP,
native_step=10,
entity_category=EntityCategory.CONFIG,
),
IronOSNumberEntityDescription(
key=PinecilNumber.QC_MAX_VOLTAGE,
translation_key=PinecilNumber.QC_MAX_VOLTAGE,
@@ -296,32 +266,6 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
IronOSNumberEntityDescription(
key=PinecilNumber.TEMP_INCREMENT_SHORT,
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
raw_value_fn=lambda value: value,
mode=NumberMode.BOX,
native_min_value=1,
native_max_value=50,
native_step=1,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
IronOSNumberEntityDescription(
key=PinecilNumber.TEMP_INCREMENT_LONG,
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
characteristic=CharSetting.TEMP_INCREMENT_LONG,
raw_value_fn=lambda value: value,
mode=NumberMode.BOX,
native_min_value=5,
native_max_value=90,
native_step=5,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
)
PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
@@ -341,6 +285,82 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
),
)
"""
The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities.
These entities represent user-defined input values, not measured temperatures, and their
interpretation depends on the device's current unit configuration. Applying a device_class
results in automatic unit conversions, which introduce rounding errors due to the use of integers.
This can prevent the correct value from being set, as the input is modified during synchronization with the device.
"""
PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
IronOSNumberEntityDescription(
key=PinecilNumber.SLEEP_TEMP,
translation_key=PinecilNumber.SLEEP_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda _, settings: settings.get("sleep_temp"),
characteristic=CharSetting.SLEEP_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_max_value=MAX_TEMP,
native_min_value_f=MIN_TEMP_F,
native_max_value_f=MAX_TEMP_F,
native_step=10,
entity_category=EntityCategory.CONFIG,
),
IronOSNumberEntityDescription(
key=PinecilNumber.BOOST_TEMP,
translation_key=PinecilNumber.BOOST_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda _, settings: settings.get("boost_temp"),
characteristic=CharSetting.BOOST_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_BOOST_TEMP,
native_min_value_f=MIN_BOOST_TEMP_F,
native_max_value=MAX_TEMP,
native_max_value_f=MAX_TEMP_F,
native_step=10,
entity_category=EntityCategory.CONFIG,
),
IronOSNumberEntityDescription(
key=PinecilNumber.TEMP_INCREMENT_SHORT,
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
raw_value_fn=lambda value: value,
mode=NumberMode.BOX,
native_min_value=1,
native_max_value=50,
native_step=1,
entity_category=EntityCategory.CONFIG,
),
IronOSNumberEntityDescription(
key=PinecilNumber.TEMP_INCREMENT_LONG,
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
characteristic=CharSetting.TEMP_INCREMENT_LONG,
raw_value_fn=lambda value: value,
mode=NumberMode.BOX,
native_min_value=5,
native_max_value=90,
native_step=5,
entity_category=EntityCategory.CONFIG,
),
)
PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription(
key=PinecilNumber.SETPOINT_TEMP,
translation_key=PinecilNumber.SETPOINT_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data, _: data.setpoint_temp,
characteristic=CharSetting.SETPOINT_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_max_value=MAX_TEMP,
native_min_value_f=MIN_TEMP_F,
native_max_value_f=MAX_TEMP_F,
native_step=5,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -354,9 +374,18 @@ async def async_setup_entry(
if coordinators.live_data.v223_features:
descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
async_add_entities(
entities = [
IronOSNumberEntity(coordinators, description) for description in descriptions
]
entities.extend(
IronOSTemperatureNumberEntity(coordinators, description)
for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS
)
entities.append(
IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION)
)
async_add_entities(entities)
class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
@@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
self.coordinator.data, self.settings.data
)
@property
def native_max_value(self) -> float:
"""Return sensor state."""
if self.entity_description.max_value_fn is not None:
return self.entity_description.max_value_fn(self.coordinator.data)
return self.entity_description.native_max_value or DEFAULT_MAX_VALUE
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
@@ -407,3 +427,60 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
)
)
await self.settings.async_request_refresh()
class IronOSTemperatureNumberEntity(IronOSNumberEntity):
"""Implementation of a IronOS temperature number entity."""
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of the sensor, if any."""
return (
UnitOfTemperature.FAHRENHEIT
if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT
else UnitOfTemperature.CELSIUS
)
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
return (
self.entity_description.native_min_value_f
if self.entity_description.native_min_value_f
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
else super().native_min_value
)
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
return (
self.entity_description.native_max_value_f
if self.entity_description.native_max_value_f
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
else super().native_max_value
)
class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity):
"""IronOS setpoint temperature entity."""
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
return (
min(
TemperatureConverter.convert(
float(max_tip_c),
UnitOfTemperature.CELSIUS,
self.native_unit_of_measurement,
),
super().native_max_value,
)
if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None
else super().native_max_value
)

View File

@@ -91,7 +91,7 @@ from .schema import (
TimeSchema,
WeatherSchema,
)
from .services import register_knx_services
from .services import async_setup_services
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
from .websocket import register_panel
@@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if (conf := config.get(DOMAIN)) is not None:
hass.data[_KNX_YAML_CONFIG] = dict(conf)
register_knx_services(hass)
async_setup_services(hass)
return True

View File

@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
@callback
def register_knx_services(hass: HomeAssistant) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register KNX integration services."""
hass.services.async_register(
DOMAIN,

View File

@@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SERVICE_CODE
from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription):
on_label: str
off_value: str
off_label: str
installer_required: bool = False
SWITCH_SETTINGS_DATA = [
@@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [
off_value="2",
off_label="Automatic economical",
),
PlenticoreSwitchEntityDescription(
module_id="devices:local",
key="Battery:ManualCharge",
name="Battery Manual Charge",
is_on="1",
on_value="1",
on_label="On",
off_value="0",
off_label="Off",
installer_required=True,
),
]
@@ -73,7 +86,13 @@ async def async_setup_entry(
description.key,
)
continue
if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required:
_LOGGER.debug(
"Skipping installer required setting data %s/%s",
description.module_id,
description.key,
)
continue
entities.append(
PlenticoreDataSwitch(
settings_data_update_coordinator,

View File

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

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"]
}

View File

@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
"requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"]
}

View File

@@ -780,10 +780,10 @@
"battery_level": {
"name": "Battery",
"state": {
"high": "Full",
"high": "[%key:common::state::full%]",
"mid": "[%key:common::state::medium%]",
"low": "[%key:common::state::low%]",
"warning": "Empty"
"warning": "[%key:common::state::empty%]"
}
},
"relative_to_start": {

View File

@@ -70,7 +70,7 @@
"motor_fault_short": "Motor shorted",
"motor_ot_amps": "Motor overtorqued",
"motor_disconnected": "Motor disconnected",
"empty": "Empty"
"empty": "[%key:common::state::empty%]"
}
},
"last_seen": {

View File

@@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonObjectType, load_json_object
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
from .services import register_services
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
config[CONF_COMMANDS],
)
register_services(hass)
async_setup_services(hass)
return True

View File

@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from .const import (
@@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None:
await matrix_bot.handle_send_message(call)
def register_services(hass: HomeAssistant) -> None:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Matrix bot component."""
hass.services.async_register(

View File

@@ -2,9 +2,12 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
from chip.clusters import Objects as clusters
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from matter_server.common import custom_clusters
from homeassistant.components.number import (
@@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip
"""Describe Matter Number Input entities."""
@dataclass(frozen=True, kw_only=True)
class MatterRangeNumberEntityDescription(
NumberEntityDescription, MatterEntityDescription
):
"""Describe Matter Number Input entities with min and max values."""
ha_to_native_value: Callable[[Any], Any]
# attribute descriptors to get the min and max value
min_attribute: type[ClusterAttributeDescriptor]
max_attribute: type[ClusterAttributeDescriptor]
# command: a custom callback to create the command to send to the device
# the callback's argument will be the index of the selected list value
command: Callable[[int], ClusterCommand]
class MatterNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity."""
@@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity):
self._attr_native_value = value
class MatterRangeNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity with min and max values."""
entity_description: MatterRangeNumberEntityDescription
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_native_value(value)
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_native_value = value
self._attr_native_min_value = (
cast(
int,
self.get_matter_attribute_value(self.entity_description.min_attribute),
)
/ 100
)
self._attr_native_max_value = (
cast(
int,
self.get_matter_attribute_value(self.entity_description.max_attribute),
)
/ 100
)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterRangeNumberEntityDescription(
key="TemperatureControlTemperatureSetpoint",
name=None,
translation_key="temperature_setpoint",
command=lambda value: clusters.TemperatureControl.Commands.SetTemperature(
targetTemperature=value
),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
measurement_to_ha=lambda x: None if x is None else x / 100,
ha_to_native_value=lambda x: round(x * 100),
min_attribute=clusters.TemperatureControl.Attributes.MinTemperature,
max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature,
mode=NumberMode.SLIDER,
),
entity_class=MatterRangeNumber,
required_attributes=(
clusters.TemperatureControl.Attributes.TemperatureSetpoint,
clusters.TemperatureControl.Attributes.MinTemperature,
clusters.TemperatureControl.Attributes.MaxTemperature,
),
),
]

View File

@@ -183,6 +183,9 @@
"temperature_offset": {
"name": "Temperature offset"
},
"temperature_setpoint": {
"name": "Temperature setpoint"
},
"pir_occupied_to_unoccupied_delay": {
"name": "Occupied to unoccupied delay"
},

View File

@@ -17,6 +17,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
@@ -67,20 +68,31 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
entity_description: StateVacuumEntityDescription
_platform_translation_key = "vacuum"
def _get_run_mode_by_tag(
self, tag: ModeTag
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
"""Get the run mode by tag."""
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for t in mode.modeTags:
if t.value == tag.value:
return mode
return None
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
# We simply set the RvcRunMode to the first runmode
# that has the idle tag to stop the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.IDLE:
# stop the vacuum by changing the run mode to idle
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
mode = self._get_run_mode_by_tag(ModeTag.IDLE)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to stop the vacuum cleaner."
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
@@ -110,14 +122,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
# We simply set the RvcRunMode to the first runmode
# that has the cleaning tag to start the vacuum cleaner.
# this is compatible with both Matter 1.2 and 1.3+ devices.
supported_run_modes = self._supported_run_modes or {}
for mode in supported_run_modes.values():
for tag in mode.modeTags:
if tag.value == ModeTag.CLEANING:
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
return
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to start the vacuum cleaner."
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
async def async_pause(self) -> None:
"""Pause the cleaning task."""

View File

@@ -6,8 +6,6 @@ import time
from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_NATIVE_PRECIPITATION,
@@ -51,13 +49,9 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def format_condition(condition: str, force_day: bool = False) -> str:
def format_condition(condition: str):
"""Return condition from dict CONDITION_MAP."""
mapped_condition = CONDITION_MAP.get(condition, condition)
if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT:
# Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny
return ATTR_CONDITION_SUNNY
return mapped_condition
return CONDITION_MAP.get(condition, condition)
async def async_setup_entry(
@@ -218,7 +212,7 @@ class MeteoFranceWeather(
forecast["dt"]
).isoformat(),
ATTR_FORECAST_CONDITION: format_condition(
forecast["weather12H"]["desc"], force_day=True
forecast["weather12H"]["desc"]
),
ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"],
ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"],

View File

@@ -9,7 +9,6 @@ from datapoint.Forecast import Forecast
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -60,7 +59,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
native_attr_name="name",
name="Station name",
icon="mdi:label-outline",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
MetOfficeSensorEntityDescription(
@@ -237,13 +235,14 @@ class MetOfficeCurrentSensor(
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
native_attr = self.entity_description.native_attr_name
value = get_attribute(
self.coordinator.data.now(), self.entity_description.native_attr_name
)
if native_attr == "name":
return str(self.coordinator.data.name)
value = get_attribute(self.coordinator.data.now(), native_attr)
if native_attr == "significantWeatherCode" and value is not None:
if (
self.entity_description.native_attr_name == "significantWeatherCode"
and value is not None
):
value = CONDITION_MAP.get(value)
return value

View File

@@ -172,7 +172,7 @@ async def async_modbus_setup(
async def async_write_register(service: ServiceCall) -> None:
"""Write Modbus registers."""
slave = 0
slave = 1
if ATTR_UNIT in service.data:
slave = int(float(service.data[ATTR_UNIT]))
@@ -195,7 +195,7 @@ async def async_modbus_setup(
async def async_write_coil(service: ServiceCall) -> None:
"""Write Modbus coil."""
slave = 0
slave = 1
if ATTR_UNIT in service.data:
slave = int(float(service.data[ATTR_UNIT]))
if ATTR_SLAVE in service.data:

View File

@@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity):
identifiers={(DOMAIN, player_id)},
manufacturer=self.player.device_info.manufacturer or provider.name,
model=self.player.device_info.model or self.player.name,
name=self.player.name,
name=self.player.display_name,
configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}",
)

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.2.3"],
"requirements": ["music-assistant-client==1.2.0"],
"zeroconf": ["_mass._tcp.local."]
}

View File

@@ -6,7 +6,11 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from music_assistant_models.enums import MediaType as MASSMediaType
from music_assistant_models.media_items import MediaItemType, SearchResults
from music_assistant_models.media_items import (
BrowseFolder,
MediaItemType,
SearchResults,
)
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -545,6 +549,8 @@ def _process_search_results(
# Add available items to results
for item in items:
if TYPE_CHECKING:
assert not isinstance(item, BrowseFolder)
if not item.available:
continue

View File

@@ -250,8 +250,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
# update generic attributes
if player.powered and active_queue is not None:
self._attr_state = MediaPlayerState(active_queue.state.value)
if player.powered and player.playback_state is not None:
self._attr_state = MediaPlayerState(player.playback_state.value)
if player.powered and player.state is not None:
self._attr_state = MediaPlayerState(player.state.value)
else:
self._attr_state = MediaPlayerState(STATE_OFF)
# active source and source list (translate to HA source names)
@@ -270,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_source = active_source_name
group_members: list[str] = []
if player.group_members:
group_members = player.group_members
if player.group_childs:
group_members = player.group_childs
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
group_members = parent.group_members
group_members = parent.group_childs
# translate MA group_members to HA group_members as entity id's
# translate MA group_childs to HA group_members as entity id's
entity_registry = er.async_get(self.hass)
group_members_entity_ids: list[str] = [
entity_id

View File

@@ -44,6 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool:
translation_key="device_communication_error",
translation_placeholders={"device": entry.title},
) from err
try:
await nam.async_check_credentials()
except (ApiError, ClientError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_communication_error",
translation_placeholders={"device": entry.title},
) from err
except AuthFailedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
@@ -25,6 +26,15 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@dataclass
class NamConfig:
"""NAM device configuration class."""
mac_address: str
auth_enabled: bool
_LOGGER = logging.getLogger(__name__)
AUTH_SCHEMA = vol.Schema(
@@ -32,14 +42,29 @@ AUTH_SCHEMA = vol.Schema(
)
async def async_get_nam(
hass: HomeAssistant, host: str, data: dict[str, Any]
) -> NettigoAirMonitor:
"""Get NAM client."""
async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig:
"""Get device MAC address and auth_enabled property."""
websession = async_get_clientsession(hass)
options = ConnectionOptions(host)
nam = await NettigoAirMonitor.create(websession, options)
mac = await nam.async_get_mac_address()
return NamConfig(mac, nam.auth_enabled)
async def async_check_credentials(
hass: HomeAssistant, host: str, data: dict[str, Any]
) -> None:
"""Check if credentials are valid."""
websession = async_get_clientsession(hass)
options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD))
return await NettigoAirMonitor.create(websession, options)
nam = await NettigoAirMonitor.create(websession, options)
await nam.async_check_credentials()
class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -47,8 +72,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_config: NamConfig
host: str
auth_enabled: bool = False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -60,20 +85,21 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
self.host = user_input[CONF_HOST]
try:
nam = await async_get_nam(self.hass, self.host, {})
config = await async_get_config(self.hass, self.host)
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
except CannotGetMacError:
return self.async_abort(reason="device_unsupported")
except AuthFailedError:
return await self.async_step_credentials()
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(config.mac_address))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
if config.auth_enabled is True:
return await self.async_step_credentials()
return self.async_create_entry(
title=self.host,
data=user_input,
@@ -93,7 +119,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
nam = await async_get_nam(self.hass, self.host, user_input)
await async_check_credentials(self.hass, self.host, user_input)
except AuthFailedError:
errors["base"] = "invalid_auth"
except (ApiError, ClientConnectorError, TimeoutError):
@@ -102,9 +128,6 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(nam.mac))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
return self.async_create_entry(
title=self.host,
data={**user_input, CONF_HOST: self.host},
@@ -125,16 +148,14 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: self.host})
try:
nam = await async_get_nam(self.hass, self.host, {})
self._config = await async_get_config(self.hass, self.host)
except (ApiError, ClientConnectorError, TimeoutError):
return self.async_abort(reason="cannot_connect")
except CannotGetMacError:
return self.async_abort(reason="device_unsupported")
except AuthFailedError:
self.auth_enabled = True
return await self.async_step_confirm_discovery()
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(self._config.mac_address))
self._abort_if_unique_id_configured({CONF_HOST: self.host})
return await self.async_step_confirm_discovery()
@@ -150,7 +171,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
data={CONF_HOST: self.host},
)
if self.auth_enabled is True:
if self._config.auth_enabled is True:
return await self.async_step_credentials()
self._set_confirm_only()
@@ -177,7 +198,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
await async_get_nam(self.hass, self.host, user_input)
await async_check_credentials(self.hass, self.host, user_input)
except (
ApiError,
AuthFailedError,
@@ -207,11 +228,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
nam = await async_get_nam(self.hass, user_input[CONF_HOST], {})
config = await async_get_config(self.hass, user_input[CONF_HOST])
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(format_mac(nam.mac))
await self.async_set_unique_id(format_mac(config.mac_address))
self._abort_if_unique_id_mismatch(reason="another_device")
return self.async_update_reload_and_abort(

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
"requirements": ["nettigo-air-monitor==5.0.0"],
"requirements": ["nettigo-air-monitor==4.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import aiohttp
from pynordpool import (
Currency,
DeliveryPeriodData,
@@ -92,8 +91,6 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
except (
NordPoolResponseError,
NordPoolError,
TimeoutError,
aiohttp.ClientError,
) as error:
LOGGER.debug("Connection error: %s", error)
self.async_set_update_error(error)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import logging
from types import MappingProxyType
import httpx
import ollama
@@ -70,6 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
@@ -80,6 +82,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""
@@ -93,12 +100,8 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
for entry in entries:
use_existing = False
# Create subentry with model from entry.data and options from entry.options
subentry_data = entry.options.copy()
subentry_data[CONF_MODEL] = entry.data[CONF_MODEL]
subentry = ConfigSubentry(
data=MappingProxyType(subentry_data),
data=entry.options,
subentry_type="conversation",
title=entry.title,
unique_id=None,
@@ -151,11 +154,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
hass.config_entries.async_update_entry(
entry,
title=DEFAULT_NAME,
# Update parent entry to only keep URL, remove model
data={CONF_URL: entry.data[CONF_URL]},
options={},
version=3,
minor_version=1,
version=2,
minor_version=2,
)
@@ -163,7 +164,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 3:
if entry.version > 2:
# This means the user has downgraded from a future version
return False
@@ -181,25 +182,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
# Update subentries to include the model
for subentry in entry.subentries.values():
if subentry.subentry_type == "conversation":
updated_data = dict(subentry.data)
updated_data[CONF_MODEL] = entry.data[CONF_MODEL]
hass.config_entries.async_update_subentry(
entry, subentry, data=MappingProxyType(updated_data)
)
# Update main entry to remove model and bump version
hass.config_entries.async_update_entry(
entry,
data={CONF_URL: entry.data[CONF_URL]},
version=3,
minor_version=1,
)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)

View File

@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
@@ -38,7 +38,6 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util.ssl import get_default_context
from . import OllamaConfigEntry
from .const import (
CONF_KEEP_ALIVE,
CONF_MAX_HISTORY,
@@ -73,43 +72,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ollama."""
VERSION = 3
MINOR_VERSION = 1
VERSION = 2
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize config flow."""
self.url: str | None = None
self.model: str | None = None
self.client: ollama.AsyncClient | None = None
self.download_task: asyncio.Task | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
user_input = user_input or {}
self.url = user_input.get(CONF_URL, self.url)
self.model = user_input.get(CONF_MODEL, self.model)
if self.url is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False
)
errors = {}
url = user_input[CONF_URL]
self._async_abort_entries_match({CONF_URL: url})
self._async_abort_entries_match({CONF_URL: self.url})
try:
url = cv.url(url)
except vol.Invalid:
errors["base"] = "invalid_url"
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
self.client = ollama.AsyncClient(
host=self.url, verify=get_default_context()
)
try:
client = ollama.AsyncClient(host=url, verify=get_default_context())
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
response = await self.client.list()
downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
except (TimeoutError, httpx.ConnectError):
errors["base"] = "cannot_connect"
except Exception:
@@ -118,69 +117,10 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
return self.async_create_entry(
title=url,
data={CONF_URL: url},
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
super().__init__()
self._name: str | None = None
self._model: str | None = None
self.download_task: asyncio.Task | None = None
self._config_data: dict[str, Any] | None = None
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
@property
def _client(self) -> ollama.AsyncClient:
"""Return the Ollama client."""
entry: OllamaConfigEntry = self._get_entry()
return entry.runtime_data
async def async_step_set_options(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle model selection and configuration step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is None:
# Get available models from Ollama server
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
response = await self._client.list()
downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
except (TimeoutError, httpx.ConnectError, httpx.HTTPError):
_LOGGER.exception("Failed to get models from Ollama server")
return self.async_abort(reason="cannot_connect")
if self.model is None:
# Show models that have been downloaded first, followed by all known
# models (only latest tags).
models_to_list = [
@@ -191,69 +131,52 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
for m in sorted(MODEL_NAMES)
if m not in downloaded_models
]
if self._is_new:
options = {}
else:
options = self._get_reconfigure_subentry().data.copy()
model_step_schema = vol.Schema(
{
vol.Required(
CONF_MODEL, description={"suggested_value": DEFAULT_MODEL}
): SelectSelector(
SelectSelectorConfig(options=models_to_list, custom_value=True)
),
}
)
return self.async_show_form(
step_id="set_options",
data_schema=vol.Schema(
ollama_config_option_schema(
self.hass, self._is_new, options, models_to_list
)
),
step_id="user",
data_schema=model_step_schema,
)
self._model = user_input[CONF_MODEL]
if self._is_new:
self._name = user_input.pop(CONF_NAME)
if self.model not in downloaded_models:
# Ollama server needs to download model first
return await self.async_step_download()
# Check if model needs to be downloaded
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
response = await self._client.list()
currently_downloaded_models: set[str] = {
model_info["model"] for model_info in response.get("models", [])
}
if self._model not in currently_downloaded_models:
# Store the user input to use after download
self._config_data = user_input
# Ollama server needs to download model first
return await self.async_step_download()
except Exception:
_LOGGER.exception("Failed to check model availability")
return self.async_abort(reason="cannot_connect")
# Model is already downloaded, create/update the entry
if self._is_new:
return self.async_create_entry(
title=self._name,
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
return self.async_create_entry(
title=self.url,
data={CONF_URL: self.url, CONF_MODEL: self.model},
subentries=[
{
"subentry_type": "conversation",
"data": {},
"title": _get_title(self.model),
"unique_id": None,
}
],
)
async def async_step_download(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
) -> ConfigFlowResult:
"""Step to wait for Ollama server to download a model."""
assert self._model is not None
assert self.model is not None
assert self.client is not None
if self.download_task is None:
# Tell Ollama server to pull the model.
# The task will block until the model and metadata are fully
# downloaded.
self.download_task = self.hass.async_create_background_task(
self._client.pull(self._model),
f"Downloading {self._model}",
self.client.pull(self.model),
f"Downloading {self.model}",
)
if self.download_task.done():
@@ -269,28 +192,80 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
progress_task=self.download_task,
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step after model downloading has succeeded."""
assert self.url is not None
assert self.model is not None
return self.async_create_entry(
title=_get_title(self.model),
data={CONF_URL: self.url, CONF_MODEL: self.model},
subentries=[
{
"subentry_type": "conversation",
"data": {},
"title": _get_title(self.model),
"unique_id": None,
}
],
)
async def async_step_failed(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
) -> ConfigFlowResult:
"""Step after model downloading has failed."""
return self.async_abort(reason="download_failed")
async def async_step_finish(
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
async def async_step_set_options(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Step after model downloading has succeeded."""
assert self._config_data is not None
"""Set conversation options."""
# abort if entry is not loaded
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
# Model download completed, create/update the entry with stored config
if self._is_new:
errors: dict[str, str] = {}
if user_input is None:
if self._is_new:
options = {}
else:
options = self._get_reconfigure_subentry().data.copy()
elif self._is_new:
return self.async_create_entry(
title=self._name,
data=self._config_data,
title=user_input.pop(CONF_NAME),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=self._config_data,
else:
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
schema = ollama_config_option_schema(self.hass, self._is_new, options)
return self.async_show_form(
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
)
async_step_user = async_step_set_options
@@ -298,14 +273,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
def ollama_config_option_schema(
hass: HomeAssistant,
is_new: bool,
options: Mapping[str, Any],
models_to_list: list[SelectOptionDict],
hass: HomeAssistant, is_new: bool, options: Mapping[str, Any]
) -> dict:
"""Ollama options schema."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
]
if is_new:
schema: dict = {
schema: dict[vol.Required | vol.Optional, Any] = {
vol.Required(CONF_NAME, default="Ollama Conversation"): str,
}
else:
@@ -313,12 +293,6 @@ def ollama_config_option_schema(
schema.update(
{
vol.Required(
CONF_MODEL,
description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)},
): SelectSelector(
SelectSelectorConfig(options=models_to_list, custom_value=True)
),
vol.Optional(
CONF_PROMPT,
description={
@@ -330,18 +304,7 @@ def ollama_config_option_schema(
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
],
multiple=True,
)
),
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Optional(
CONF_NUM_CTX,
description={
@@ -387,3 +350,11 @@ def ollama_config_option_schema(
)
return schema
def _get_title(model: str) -> str:
"""Get title for config entry."""
if model.endswith(":latest"):
model = model.split(":", maxsplit=1)[0]
return model

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Literal
from homeassistant.components import assist_pipeline, conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
@@ -56,9 +56,6 @@ class OllamaConversationEntity(
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -102,10 +99,3 @@ class OllamaConversationEntity(
conversation_id=chat_log.conversation_id,
continue_conversation=chat_log.continue_conversation,
)
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -166,14 +166,11 @@ class OllamaBaseLLMEntity(Entity):
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
model, _, version = subentry.data[CONF_MODEL].partition(":")
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Ollama",
model=model,
sw_version=version or "latest",
model=entry.data[CONF_MODEL],
entry_type=dr.DeviceEntryType.SERVICE,
)

View File

@@ -3,17 +3,24 @@
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]"
"url": "[%key:common::config_flow::data::url%]",
"model": "Model"
}
},
"download": {
"title": "Downloading model"
}
},
"abort": {
"download_failed": "Model downloading failed",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"invalid_url": "[%key:common::config_flow::error::invalid_host%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"progress": {
"download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details."
}
},
"config_subentries": {
@@ -26,7 +33,6 @@
"step": {
"set_options": {
"data": {
"model": "Model",
"name": "[%key:common::config_flow::data::name%]",
"prompt": "Instructions",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
@@ -41,19 +47,11 @@
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.",
"think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency."
}
},
"download": {
"title": "Downloading model"
}
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"entry_not_loaded": "Failed to add agent. The configuration is disabled.",
"download_failed": "Model downloading failed",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"progress": {
"download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details."
"entry_not_loaded": "Cannot add things while the configuration is disabled."
}
}
}

View File

@@ -284,6 +284,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
@@ -292,6 +294,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""

View File

@@ -1,73 +1,19 @@
"""Conversation support for OpenAI."""
from collections.abc import AsyncGenerator, Callable
import json
from typing import Any, Literal, cast
import openai
from openai._streaming import AsyncStream
from openai.types.responses import (
EasyInputMessageParam,
FunctionToolParam,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallParam,
ResponseIncompleteEvent,
ResponseInputParam,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputMessageParam,
ResponseReasoningItem,
ResponseReasoningItemParam,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ToolParam,
WebSearchToolParam,
)
from openai.types.responses.response_input_param import FunctionCallOutput
from openai.types.responses.web_search_tool_param import UserLocation
from voluptuous_openapi import convert
from typing import Literal
from homeassistant.components import assist_pipeline, conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.config_entries import 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 . import OpenAIConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_CONTEXT_SIZE,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
)
from .const import CONF_PROMPT, DOMAIN
from .entity import OpenAIBaseLLMEntity
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
async def async_setup_entry(
@@ -86,152 +32,10 @@ async def async_setup_entry(
)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> FunctionToolParam:
"""Format tool specification."""
return FunctionToolParam(
type="function",
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
description=tool.description,
strict=False,
)
def _convert_content_to_param(
content: conversation.Content,
) -> ResponseInputParam:
"""Convert any native chat message for this agent to the native format."""
messages: ResponseInputParam = []
if isinstance(content, conversation.ToolResultContent):
return [
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
)
]
if content.content:
role: Literal["user", "assistant", "system", "developer"] = content.role
if role == "system":
role = "developer"
messages.append(
EasyInputMessageParam(type="message", role=role, content=content.content)
)
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
messages.extend(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json.dumps(tool_call.tool_args),
call_id=tool_call.id,
)
for tool_call in content.tool_calls
)
return messages
async def _transform_stream(
chat_log: conversation.ChatLog,
result: AsyncStream[ResponseStreamEvent],
messages: ResponseInputParam,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
async for event in result:
LOGGER.debug("Received event: %s", event)
if isinstance(event, ResponseOutputItemAddedEvent):
if isinstance(event.item, ResponseOutputMessage):
yield {"role": event.item.role}
elif isinstance(event.item, ResponseFunctionToolCall):
# OpenAI has tool calls as individual events
# while HA puts tool calls inside the assistant message.
# We turn them into individual assistant content for HA
# to ensure that tools are called as soon as possible.
yield {"role": "assistant"}
current_tool_call = event.item
elif isinstance(event, ResponseOutputItemDoneEvent):
item = event.item.model_dump()
item.pop("status", None)
if isinstance(event.item, ResponseReasoningItem):
messages.append(cast(ResponseReasoningItemParam, item))
elif isinstance(event.item, ResponseOutputMessage):
messages.append(cast(ResponseOutputMessageParam, item))
elif isinstance(event.item, ResponseFunctionToolCall):
messages.append(cast(ResponseFunctionToolCallParam, item))
elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
current_tool_call.arguments += event.delta
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
current_tool_call.status = "completed"
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call.call_id,
tool_name=current_tool_call.name,
tool_args=json.loads(current_tool_call.arguments),
)
]
}
elif isinstance(event, ResponseCompletedEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
elif isinstance(event, ResponseIncompleteEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
if (
event.response.incomplete_details
and event.response.incomplete_details.reason
):
reason: str = event.response.incomplete_details.reason
else:
reason = "unknown reason"
if reason == "max_output_tokens":
reason = "max output tokens reached"
elif reason == "content_filter":
reason = "content filter triggered"
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
elif isinstance(event, ResponseFailedEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
reason = "unknown reason"
if event.response.error is not None:
reason = event.response.error.message
raise HomeAssistantError(f"OpenAI response failed: {reason}")
elif isinstance(event, ResponseErrorEvent):
raise HomeAssistantError(f"OpenAI response error: {event.message}")
class OpenAIConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
conversation.ConversationEntity,
conversation.AbstractConversationAgent,
OpenAIBaseLLMEntity,
):
"""OpenAI conversation agent."""
@@ -239,17 +43,7 @@ class OpenAIConversationEntity(
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="OpenAI",
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
entry_type=dr.DeviceEntryType.SERVICE,
)
super().__init__(entry, subentry)
if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
@@ -267,9 +61,6 @@ class OpenAIConversationEntity(
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -304,95 +95,3 @@ class OpenAIConversationEntity(
conversation_id=chat_log.conversation_id,
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.subentry.data
tools: list[ToolParam] | 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
]
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchToolParam(
type="web_search_preview",
search_context_size=options.get(
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = UserLocation(
type="approximate",
city=options.get(CONF_WEB_SEARCH_CITY, ""),
region=options.get(CONF_WEB_SEARCH_REGION, ""),
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
)
if tools is None:
tools = []
tools.append(web_search)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
messages = [
m
for content in chat_log.content
for m in _convert_content_to_param(content)
]
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"input": messages,
"max_output_tokens": options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
"user": chat_log.conversation_id,
"stream": True,
}
if tools:
model_args["tools"] = tools
if model.startswith("o"):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
)
}
else:
model_args["store"] = False
try:
result = await client.responses.create(**model_args)
except openai.RateLimitError as err:
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, _transform_stream(chat_log, result, messages)
):
if not isinstance(content, conversation.AssistantContent):
messages.extend(_convert_content_to_param(content))
if not chat_log.unresponded_tool_results:
break
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,314 @@
"""Base entity for OpenAI."""
from collections.abc import AsyncGenerator, Callable
import json
from typing import Any, Literal, cast
import openai
from openai._streaming import AsyncStream
from openai.types.responses import (
EasyInputMessageParam,
FunctionToolParam,
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionCallArgumentsDoneEvent,
ResponseFunctionToolCall,
ResponseFunctionToolCallParam,
ResponseIncompleteEvent,
ResponseInputParam,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseOutputMessage,
ResponseOutputMessageParam,
ResponseReasoningItem,
ResponseReasoningItemParam,
ResponseStreamEvent,
ResponseTextDeltaEvent,
ToolParam,
WebSearchToolParam,
)
from openai.types.responses.response_input_param import FunctionCallOutput
from openai.types.responses.web_search_tool_param import UserLocation
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from . import OpenAIConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_CONTEXT_SIZE,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> FunctionToolParam:
"""Format tool specification."""
return FunctionToolParam(
type="function",
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
description=tool.description,
strict=False,
)
def _convert_content_to_param(
content: conversation.Content,
) -> ResponseInputParam:
"""Convert any native chat message for this agent to the native format."""
messages: ResponseInputParam = []
if isinstance(content, conversation.ToolResultContent):
return [
FunctionCallOutput(
type="function_call_output",
call_id=content.tool_call_id,
output=json.dumps(content.tool_result),
)
]
if content.content:
role: Literal["user", "assistant", "system", "developer"] = content.role
if role == "system":
role = "developer"
messages.append(
EasyInputMessageParam(type="message", role=role, content=content.content)
)
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
messages.extend(
ResponseFunctionToolCallParam(
type="function_call",
name=tool_call.tool_name,
arguments=json.dumps(tool_call.tool_args),
call_id=tool_call.id,
)
for tool_call in content.tool_calls
)
return messages
async def _transform_stream(
chat_log: conversation.ChatLog,
result: AsyncStream[ResponseStreamEvent],
messages: ResponseInputParam,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
async for event in result:
LOGGER.debug("Received event: %s", event)
if isinstance(event, ResponseOutputItemAddedEvent):
if isinstance(event.item, ResponseOutputMessage):
yield {"role": event.item.role}
elif isinstance(event.item, ResponseFunctionToolCall):
# OpenAI has tool calls as individual events
# while HA puts tool calls inside the assistant message.
# We turn them into individual assistant content for HA
# to ensure that tools are called as soon as possible.
yield {"role": "assistant"}
current_tool_call = event.item
elif isinstance(event, ResponseOutputItemDoneEvent):
item = event.item.model_dump()
item.pop("status", None)
if isinstance(event.item, ResponseReasoningItem):
messages.append(cast(ResponseReasoningItemParam, item))
elif isinstance(event.item, ResponseOutputMessage):
messages.append(cast(ResponseOutputMessageParam, item))
elif isinstance(event.item, ResponseFunctionToolCall):
messages.append(cast(ResponseFunctionToolCallParam, item))
elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
current_tool_call.arguments += event.delta
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
current_tool_call.status = "completed"
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call.call_id,
tool_name=current_tool_call.name,
tool_args=json.loads(current_tool_call.arguments),
)
]
}
elif isinstance(event, ResponseCompletedEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
elif isinstance(event, ResponseIncompleteEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
if (
event.response.incomplete_details
and event.response.incomplete_details.reason
):
reason: str = event.response.incomplete_details.reason
else:
reason = "unknown reason"
if reason == "max_output_tokens":
reason = "max output tokens reached"
elif reason == "content_filter":
reason = "content filter triggered"
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
elif isinstance(event, ResponseFailedEvent):
if event.response.usage is not None:
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
}
}
)
reason = "unknown reason"
if event.response.error is not None:
reason = event.response.error.message
raise HomeAssistantError(f"OpenAI response failed: {reason}")
elif isinstance(event, ResponseErrorEvent):
raise HomeAssistantError(f"OpenAI response error: {event.message}")
class OpenAIBaseLLMEntity(Entity):
"""OpenAI conversation agent."""
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="OpenAI",
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
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[ToolParam] | 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
]
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchToolParam(
type="web_search_preview",
search_context_size=options.get(
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = UserLocation(
type="approximate",
city=options.get(CONF_WEB_SEARCH_CITY, ""),
region=options.get(CONF_WEB_SEARCH_REGION, ""),
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
)
if tools is None:
tools = []
tools.append(web_search)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
messages = [
m
for content in chat_log.content
for m in _convert_content_to_param(content)
]
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"input": messages,
"max_output_tokens": options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
"user": chat_log.conversation_id,
"stream": True,
}
if tools:
model_args["tools"] = tools
if model.startswith("o"):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
)
}
else:
model_args["store"] = False
try:
result = await client.responses.create(**model_args)
except openai.RateLimitError as err:
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, _transform_stream(chat_log, result, messages)
):
if not isinstance(content, conversation.AssistantContent):
messages.extend(_convert_content_to_param(content))
if not chat_log.unresponded_tool_results:
break

View File

@@ -123,7 +123,7 @@
"sensor": {
"battery": {
"state": {
"full": "Full",
"full": "[%key:common::state::full%]",
"low": "[%key:common::state::low%]",
"normal": "[%key:common::state::normal%]",
"medium": "[%key:common::state::medium%]",

View File

@@ -2,17 +2,23 @@
"config": {
"step": {
"user": {
"description": "Select the area in which you want to search for water measuring stations",
"data": {
"location": "[%key:common::config_flow::data::location%]",
"radius": "Search radius"
},
"data_description": {
"location": "Pick the location where to search for water measuring stations.",
"radius": "The radius to search for water measuring stations around the selected location."
}
},
"select_station": {
"title": "Select the measuring station to add",
"title": "Select the station to add",
"description": "Found {stations_count} stations in radius",
"data": {
"station": "Station"
},
"data_description": {
"station": "Select the water measuring station you want to add to Home Assistant."
}
}
},

View File

@@ -14,7 +14,7 @@ from psnawp_api.models.user import User
from psnawp_api.utils.misc import parse_npsso_token
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME
from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK
@@ -76,13 +76,23 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure flow for PlayStation Network integration."""
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
entry = (
self._get_reauth_entry()
if self.source == SOURCE_REAUTH
else self._get_reconfigure_entry()
)
if user_input is not None:
try:
@@ -113,7 +123,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="reauth_confirm",
step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),

View File

@@ -7,13 +7,13 @@ import logging
from psnawp_api.core.psnawp_exceptions import (
PSNAWPAuthenticationError,
PSNAWPClientError,
PSNAWPServerError,
)
from psnawp_api.models.user import User
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -28,7 +28,6 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
"""Data update coordinator for PSN."""
config_entry: PlaystationNetworkConfigEntry
user: User
def __init__(
self,
@@ -51,12 +50,17 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
"""Set up the coordinator."""
try:
self.user = await self.psn.get_user()
await self.psn.get_user()
except PSNAWPAuthenticationError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="not_ready",
) from error
except (PSNAWPServerError, PSNAWPClientError) as error:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
) from error
async def _async_update_data(self) -> PlaystationNetworkData:
"""Get the latest data from the PSN."""
@@ -67,7 +71,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
translation_domain=DOMAIN,
translation_key="not_ready",
) from error
except PSNAWPServerError as error:
except (PSNAWPServerError, PSNAWPClientError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",

View File

@@ -26,6 +26,9 @@
},
"online_id": {
"default": "mdi:account"
},
"last_online": {
"default": "mdi:account-clock"
}
}
}

View File

@@ -63,7 +63,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum

View File

@@ -4,16 +4,22 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import (
@@ -29,7 +35,7 @@ PARALLEL_UPDATES = 0
class PlaystationNetworkSensorEntityDescription(SensorEntityDescription):
"""PlayStation Network sensor description."""
value_fn: Callable[[PlaystationNetworkData], StateType]
value_fn: Callable[[PlaystationNetworkData], StateType | datetime]
entity_picture: str | None = None
@@ -43,6 +49,7 @@ class PlaystationNetworkSensor(StrEnum):
EARNED_TROPHIES_SILVER = "earned_trophies_silver"
EARNED_TROPHIES_BRONZE = "earned_trophies_bronze"
ONLINE_ID = "online_id"
LAST_ONLINE = "last_online"
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
@@ -102,6 +109,16 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
translation_key=PlaystationNetworkSensor.ONLINE_ID,
value_fn=lambda psn: psn.username,
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.LAST_ONLINE,
translation_key=PlaystationNetworkSensor.LAST_ONLINE,
value_fn=(
lambda psn: dt_util.parse_datetime(
psn.presence["basicPresence"]["lastAvailableDate"]
)
),
device_class=SensorDeviceClass.TIMESTAMP,
),
)
@@ -147,7 +164,7 @@ class PlaystationNetworkSensorEntity(
)
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -19,6 +19,16 @@
"data_description": {
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
}
},
"reconfigure": {
"title": "Update PlayStation Network configuration",
"description": "[%key:component::playstation_network::config::step::user::description%]",
"data": {
"npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]"
},
"data_description": {
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
}
}
},
"error": {
@@ -30,7 +40,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**"
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"exceptions": {
@@ -67,6 +78,9 @@
},
"online_id": {
"name": "Online-ID"
},
"last_online": {
"name": "Last online"
}
}
}

View File

@@ -27,10 +27,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) ->
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(coordinator.api.gateway_id))},
manufacturer="Plugwise",
model=coordinator.api.smile_model,
model_id=coordinator.api.smile_model_id,
name=coordinator.api.smile_name,
sw_version=str(coordinator.api.smile_version),
model=coordinator.api.smile.model,
model_id=coordinator.api.smile.model_id,
name=coordinator.api.smile.name,
sw_version=str(coordinator.api.smile.version),
) # required for adding the entity-less P1 Gateway
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -39,7 +39,7 @@ async def async_setup_entry(
if not coordinator.new_devices:
return
if coordinator.api.smile_name == "Adam":
if coordinator.api.smile.name == "Adam":
async_add_entities(
PlugwiseClimateEntity(coordinator, device_id)
for device_id in coordinator.new_devices
@@ -85,7 +85,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
if (
self.coordinator.api.cooling_present
and coordinator.api.smile_name != "Adam"
and coordinator.api.smile.name != "Adam"
):
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE

View File

@@ -204,11 +204,11 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
api, errors = await verify_connection(self.hass, user_input)
if api:
await self.async_set_unique_id(
api.smile_hostname or api.gateway_id,
api.smile.hostname or api.gateway_id,
raise_on_progress=False,
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=api.smile_name, data=user_input)
return self.async_create_entry(title=api.smile.name, data=user_input)
return self.async_show_form(
step_id=SOURCE_USER,
@@ -236,7 +236,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
api, errors = await verify_connection(self.hass, full_input)
if api:
await self.async_set_unique_id(
api.smile_hostname or api.gateway_id,
api.smile.hostname or api.gateway_id,
raise_on_progress=False,
)
self._abort_if_unique_id_mismatch(reason="not_the_same_smile")

View File

@@ -48,7 +48,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]):
manufacturer=data.get("vendor"),
model=data.get("model"),
model_id=data.get("model_id"),
name=coordinator.api.smile_name,
name=coordinator.api.smile.name,
sw_version=data.get("firmware"),
hw_version=data.get("hardware"),
)

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.7.6"],
"requirements": ["plugwise==1.7.7"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.28.2"]
"requirements": ["bluetooth-data-tools==1.28.1"]
}

View File

@@ -1,13 +1,13 @@
{
"title": "Proximity",
"config": {
"flow_title": "Proximity",
"flow_title": "[%key:component::proximity::title%]",
"step": {
"user": {
"data": {
"zone": "Zone to track distance to",
"ignored_zones": "Zones to ignore",
"tracked_entities": "Devices or Persons to track",
"tracked_entities": "Devices or persons to track",
"tolerance": "Tolerance distance"
}
}
@@ -21,10 +21,10 @@
"step": {
"init": {
"data": {
"zone": "Zone to track distance to",
"ignored_zones": "Zones to ignore",
"tracked_entities": "Devices or Persons to track",
"tolerance": "Tolerance distance"
"zone": "[%key:component::proximity::config::step::user::data::zone%]",
"ignored_zones": "[%key:component::proximity::config::step::user::data::ignored_zones%]",
"tracked_entities": "[%key:component::proximity::config::step::user::data::tracked_entities%]",
"tolerance": "[%key:component::proximity::config::step::user::data::tolerance%]"
}
}
}
@@ -32,7 +32,7 @@
"entity": {
"sensor": {
"dir_of_travel": {
"name": "{tracked_entity} Direction of travel",
"name": "{tracked_entity} direction of travel",
"state": {
"arrived": "Arrived",
"away_from": "Away from",
@@ -40,15 +40,15 @@
"towards": "Towards"
}
},
"dist_to_zone": { "name": "{tracked_entity} Distance" },
"dist_to_zone": { "name": "{tracked_entity} distance" },
"nearest": { "name": "Nearest device" },
"nearest_dir_of_travel": {
"name": "Nearest direction of travel",
"state": {
"arrived": "Arrived",
"away_from": "Away from",
"stationary": "Stationary",
"towards": "Towards"
"arrived": "[%key:component::proximity::entity::sensor::dir_of_travel::state::arrived%]",
"away_from": "[%key:component::proximity::entity::sensor::dir_of_travel::state::away_from%]",
"stationary": "[%key:component::proximity::entity::sensor::dir_of_travel::state::stationary%]",
"towards": "[%key:component::proximity::entity::sensor::dir_of_travel::state::towards%]"
}
},
"nearest_dist_to_zone": { "name": "Nearest distance" }

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
from .renault_hub import RenaultHub
from .services import setup_services
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type RenaultConfigEntry = ConfigEntry[RenaultHub]
@@ -20,7 +20,7 @@ type RenaultConfigEntry = ConfigEntry[RenaultHub]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Renault component."""
setup_services(hass)
async_setup_services(hass)
return True

View File

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -191,7 +191,8 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
)
def setup_services(hass: HomeAssistant) -> None:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Renault services."""
hass.services.async_register(

View File

@@ -221,8 +221,8 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity):
elif command in ["off", "alloff"]:
self._state = False
# dimmable device accept 'set_level=(0-15)' commands
elif re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
self._brightness = rflink_to_brightness(int(command.split("=")[1]))
elif match := re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
self._brightness = rflink_to_brightness(int(match.group(1)))
self._state = True
@property

View File

@@ -56,10 +56,7 @@ from .coordinator import (
ShellyRpcCoordinator,
ShellyRpcPollingCoordinator,
)
from .repairs import (
async_manage_ble_scanner_firmware_unsupported_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
)
from .repairs import async_manage_ble_scanner_firmware_unsupported_issue
from .utils import (
async_create_issue_unsupported_firmware,
get_coap_context,
@@ -330,10 +327,6 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
hass,
entry,
)
async_manage_outbound_websocket_incorrectly_enabled_issue(
hass,
entry,
)
elif (
sleep_period is None
or device_entry is None

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