Compare commits

..

179 Commits

Author SHA1 Message Date
Cursor Agent
20991e49cb feat: Add multiple media selection to MediaSelector
Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-13 00:44:03 +00:00
Marc Mueller
82758f7671 Update pyheos to 1.0.6 (#154346) 2025-10-13 01:39:36 +02:00
David Recordon
7739cdc626 Update pyControl4 to v1.5.0 (#154341) 2025-10-12 23:28:08 +02:00
Michael Davie
4ca1ae61aa Environment Canada station selector (#154307)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-12 22:34:16 +02:00
Dave T
3d130a9bdf Simplify generic camera tests (#154313) 2025-10-12 22:06:13 +02:00
Shay Levy
2b38f33d50 Bump aioshelly to 13.13.0 (#154337) 2025-10-12 23:01:22 +03:00
Glenn Vandeuren (aka Iondependent)
19dedb038e Update nhc requirement to version 0.7.0 (#154250) 2025-10-12 21:58:01 +02:00
Dan Schafer
59781422f7 Update Snoo strings.json to include weaning_baseline (#154268) 2025-10-12 21:57:47 +02:00
Thomas55555
083277d1ff Add model_id to Husqvarna Automower (#154335) 2025-10-12 21:45:01 +02:00
Marcus Gustavsson
9b9c55b37b Updated prowlpy to 1.1.1 and changed the usage to do asynchronous calls (#154193) 2025-10-12 21:17:43 +02:00
J. Nick Koston
c9d67d596b Fix August integration to handle unavailable OAuth implementation at startup (#154244) 2025-10-12 09:16:22 -10:00
J. Nick Koston
7948b35265 Fix Yale integration to handle unavailable OAuth implementation at startup (#154245) 2025-10-12 09:16:02 -10:00
Ernst Klamer
be843970fd bump tilt-ble to 1.0.1 (#154320) 2025-10-12 21:38:27 +03:00
Michael Davie
53b65b2fb4 Bump env-canada to v0.12.1 (#154303)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 20:31:02 +02:00
Simone Chemelli
ac7be97245 Bump aioamazondevices to 6.4.3 (#154293) 2025-10-12 19:25:53 +02:00
Jan Bouwhuis
09e539bf0e Fix home wiziard total increasing sensors returning 0 (#154264) 2025-10-12 12:51:50 -04:00
J. Nick Koston
6ef1b3bad3 Bump aioesphomeapi to 41.14.0 (#154275) 2025-10-12 12:51:05 -04:00
Bouwe Westerdijk
38e46f7a53 Bump plugwise to v1.8.0 - add initial support for Emma (#154277) 2025-10-12 12:50:46 -04:00
Michael Davie
ef60d16659 Fix Environment Canada camera entity initialization (#154302)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 12:47:22 -04:00
Marc Mueller
bf4f8b48a3 Update pylint to 4.0.0 + astroid to 4.0.1 (#154311) 2025-10-12 12:46:04 -04:00
Denis Shulyaka
3c1496d2bb Add gpt-image-1-mini support (#154316) 2025-10-12 12:44:38 -04:00
Mick Vleeshouwer
d457787639 Move URL out of Overkiz Config Flow descriptions (#154315) 2025-10-12 18:23:24 +02:00
Mick Vleeshouwer
de4bfd6f05 Bump pyOverkiz to 1.19.0 in Overkiz (#154310) 2025-10-12 18:07:19 +02:00
Shay Levy
34c5748132 Align Shelly async_setup_entry in platforms (#154142)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2025-10-12 18:41:54 +03:00
Michael Hansen
5bfd9620db Handle Wyoming config entries with missing info (#154186) 2025-10-12 10:23:09 -05:00
Michael Davie
6f8766e4bd Update config flow strings for Environment Canada (#154242) 2025-10-12 11:49:29 +02:00
Jordan Harvey
d3b519846b Bump pyprobeplus to 1.1.0 (#154265) 2025-10-12 10:06:00 +02:00
Joakim Plate
36d952800b Move url like strings to placeholders for nibe (#154249) 2025-10-12 00:11:23 +02:00
Andrew Jackson
b832561e53 Move URL out of Mastodon strings.json (#154231) 2025-10-12 00:07:40 +02:00
Manu
c59d295bf2 Add description placeholders in Uptime Kuma config flow (#154252)
Signed-off-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com>
2025-10-12 00:06:29 +02:00
Oliver Gründel
6e28e3aed1 Move developer registration url out of strings.json file (#154261) 2025-10-12 00:04:59 +02:00
Magnus
6d8944d379 Fix multiple definition of DEFAULT_PORT and DEFAULT_RETAIN constants (#154255) 2025-10-11 23:47:18 +02:00
Joost Lekkerkerker
762fd6d241 Move URL out of Aemet strings.json (#154225) 2025-10-11 20:52:51 +03:00
Simone Chemelli
4c6500e7a4 Bump aioamazondevices to 6.4.1 (#154228) 2025-10-11 18:58:14 +02:00
Jan Bouwhuis
cdc224715f Fix inconsistent naming of MQTT test config globals (#154221) 2025-10-11 16:29:13 +02:00
Paul Bottein
648b250fc8 Bump frontend 20251001.4 (#154218) 2025-10-11 09:33:06 -04:00
Ernst Klamer
ba61562300 Bump kegtron-ble to 1.0.2 (#154207) 2025-10-11 16:27:25 +03:00
Marc Mueller
8d67182e0e [ci] No longer install setuptools + wheel by default (#154212) 2025-10-11 15:26:04 +02:00
Shay Levy
3ce1ef4c3f Use Entity Description in Shelly light platform (#154102) 2025-10-11 16:15:48 +03:00
Ludovic BOUÉ
bde4eb5011 Rename Matter SolarPower fixture to Solar inverter (#154201)
The goal is to facilitate understanding for the introduction of Matter namespaces and tags:
 - https://github.com/home-assistant/core/pull/152754
2025-10-11 12:16:37 +02:00
srirams
a58a7065b6 Remove redudant state write in Smart Meter Texas (#154126) 2025-10-11 10:32:10 +02:00
Marc Mueller
0c9b72bf1d Update pylint to 3.3.9 (#154194) 2025-10-11 09:31:36 +02:00
Matthias Alphart
541d94d8c6 Record last_reported for KNX sensor entitiy states (#154169) 2025-10-11 08:21:51 +02:00
Abílio Costa
c370c86a4f Use custom string for Oral-B no-devices-found message (#154183) 2025-10-10 22:08:38 +01:00
Paul Bottein
bc6accf4ae Add missing entity category and icons for smlight integration (#154131) 2025-10-10 21:30:32 +02:00
G Johansson
d40eeee422 Remove deprecated ConfigSource from core (#154112) 2025-10-10 18:23:13 +02:00
Erik Montnemery
c9d9730c4a Change domain and name of Nintendo Switch parental controls integration (#153893) 2025-10-10 17:11:10 +02:00
ehendrix23
d3a8f3191b Add Speech-to-Text (stt) to elevenlabs (#147838)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-10-10 17:01:22 +02:00
Thomas D
cb3829ddee Add buttons to Volvo integration (#153272)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2025-10-10 16:51:35 +02:00
tronikos
73383e6c26 Add reconfigure flow in Google Assistant SDK (#153802) 2025-10-10 16:24:46 +02:00
Matthias Alphart
217894ee8b Update knx-frontend to 2025.10.9.185845 (#154103) 2025-10-10 16:24:14 +02:00
Thomas D
c7321a337e Add device_tracker platform to Volvo integration (#153437) 2025-10-10 16:23:07 +02:00
Denis Shulyaka
517124dfbe Anthropic web search support (#153753) 2025-10-10 16:21:21 +02:00
hanwg
f49299b009 Add edit message media feature for Telegram bot (#151034) 2025-10-10 15:50:54 +02:00
Shay Levy
1001da08f6 Fix Shelly RPC cover update when the device is not initialized (#154159) 2025-10-10 16:50:45 +03:00
Lars
0da019404c Remove deprecated extra attributes from fritzbox climate (#154152) 2025-10-10 15:48:22 +02:00
Jan Čermák
9a4280d0de Add attachments support to OpenRouter AI task (#154161) 2025-10-10 15:44:33 +02:00
Lukas
c28e105df5 Pooldose update api (#153497)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-10-10 15:43:47 +02:00
Matthias Alphart
68787248f6 Update xknx to 3.9.1 (#154146) 2025-10-10 15:42:38 +02:00
Tom Matheussen
36be6b6187 Add configured number to Satel Integra subentry titles (#154155) 2025-10-10 15:27:23 +02:00
Jordan Harvey
42dea92c51 Add time platform to nintendo_parental integration (#153866)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-10-10 15:20:59 +02:00
Justus
4b828d4753 IOmeter bump version v0.2.0 (#154150) 2025-10-10 15:15:58 +02:00
Robert Resch
8e79c38f34 Bump deebot-client to 15.1.0 (#154154) 2025-10-10 16:07:45 +03:00
jvmahon
c92107b8d4 Inherit MatterEntityDescription in Matter entities (#154083) 2025-10-10 15:04:01 +02:00
epenet
b25622f40e Use SI constants in CO unit converter (#153187) 2025-10-10 14:59:20 +02:00
Petro31
e887d5e6ad Fix delay_on and auto_off with multiple triggers (#153839) 2025-10-10 14:21:11 +02:00
TheJulianJES
1f19e40cfe Adjust OTBR config entry name for ZBT-2 (#153940) 2025-10-10 14:19:08 +02:00
Bram Kragten
3d2d2271d3 Update frontend to 20251001.2 (#154143) 2025-10-10 14:08:17 +02:00
Jack Thomasson
d1dd5eecd6 use a consistent python version for uv (#154022) 2025-10-10 13:59:45 +02:00
Jan Bouwhuis
cdec29ffb7 Add MQTT select subentry support (#153637) 2025-10-10 13:46:21 +02:00
peteS-UK
07f3e00f18 Fix for multiple Lyrion Music Server on a single Home Assistant server for Squeezebox (#154081) 2025-10-10 13:36:46 +02:00
starkillerOG
084d029168 Add Reolink survaillance rule switch entities (#154132)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-10 13:35:07 +02:00
tronikos
17e997ee18 Add module-level statistics to SolarEdge (#152581) 2025-10-10 13:07:39 +02:00
J. Diego Rodríguez Royo
16d4c6c95a Add Spotless series features to Home Connect integration (#153016) 2025-10-10 13:00:17 +02:00
epenet
0205a636ef Filter out invalid Renault vehicles (#154070)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-10 12:52:21 +02:00
hanwg
4707fd2f94 Update quality scale for Telegram bot (#154122) 2025-10-10 12:47:55 +02:00
J. Nick Koston
ad3cadab83 Bump propcache to 0.4.1 (#154033) 2025-10-10 11:45:58 +01:00
Jordan Harvey
3fce815415 Add reauthentication to Nintendo Switch Parental controls integration (#154077) 2025-10-10 12:31:46 +02:00
Erik Montnemery
ee67619cb1 Add mg/m³ as a valid UOM for sensor/number Carbon Monoxide device class (#154074)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-10 11:31:31 +01:00
TheJulianJES
1a744a2c91 Fix HA hardware configuration message for Thread without HAOS (#153933) 2025-10-10 11:37:43 +02:00
Erik Montnemery
951978e483 Include unit class in units_changed statistics issue (#154069) 2025-10-10 10:50:40 +03:00
Marcus Gustavsson
54d30377d3 Add ConfigFlow to Prowl integration (#133771)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-10-10 09:41:45 +02:00
Shay Levy
eb04dda197 Use Entity Description in Shelly BLU TRV button (#154118) 2025-10-10 10:24:08 +03:00
Fabien Kleinbourg
1e192aadfa sharkiq dependency bump to 1.4.2 (#153931) 2025-10-10 08:40:20 +02:00
Erwin Douna
6f680f3d03 Portainer fix offline endpoint (#154101) 2025-10-10 08:14:49 +02:00
starkillerOG
f0663dc275 Bump reolink-aio to 0.16.2 (#154117) 2025-10-10 02:20:11 +03:00
Paulus Schoutsen
96bb67bef9 Z-Wave: ESPHome discovery to update all options (#154113) 2025-10-09 17:14:53 -04:00
G Johansson
929d76e236 Add validation for ObjectSelector (#153081) 2025-10-09 21:46:03 +02:00
Artur Pragacz
fe1ff083de Improve comments in the core config (#154096) 2025-10-09 21:08:16 +02:00
puddly
90c68f8ad0 Prevent reloading the ZHA integration while adapter firmware is being updated (#152626) 2025-10-09 21:00:02 +02:00
Shay Levy
6b79aa7738 Use Entity Description in Shelly cover platform (#154085) 2025-10-09 21:04:06 +03:00
Joost Lekkerkerker
f6fb4c8d5a Add unique id to nederlandse spoorwegen (#154013) 2025-10-09 19:00:47 +02:00
hanwg
a6e575ecfa Add diagnostics for Telegram bot (#154016) 2025-10-09 18:20:00 +02:00
Thomas D
85392ae167 Bump dependency for Volvo integration (#154084) 2025-10-09 18:15:22 +02:00
G Johansson
9d124be491 Remove deprecated set state directly in alarmcontrolpanel (#154038) 2025-10-09 18:06:13 +02:00
G Johansson
8bca3931ab Remove deprecated cover state constants (#154037) 2025-10-09 18:05:49 +02:00
Kevin McCormack
0367a01287 Enable strict typing for GitHub integration (#154048) 2025-10-09 17:50:24 +02:00
Manu
86e2c2f361 Add jet lag prevention event support to Sleep as Android integration (#154075) 2025-10-09 17:48:44 +02:00
Daniel De Sousa
335c8e50a2 Add switchbot_cloud climate TURN_OFF, TURN_ON support. (#154017) 2025-10-09 17:47:26 +02:00
eskerda
8152a9e5da Update Citybikes component with third-party library and fields (#151009) 2025-10-09 17:27:31 +02:00
Felipe Santos
250e562caf Fix devcontainer mistakenly using Python 3.14 (#154046) 2025-10-09 17:25:59 +02:00
Maciej Bieniek
a3b641e53d Bump brother to version 5.1.1 (#154080) 2025-10-09 17:00:41 +02:00
Shay Levy
135ea4c02e Fix Shelly orphaned entity removal logic (#154031) 2025-10-09 16:21:58 +03:00
Simone Chemelli
bc980c1212 Bump aioamazondevices to 6.4.0 (#154071) 2025-10-09 15:20:25 +02:00
Shay Levy
59ca88a7e8 Update Shelly block valve platform to use entity description (#154068) 2025-10-09 12:05:19 +03:00
Erik Montnemery
d45114cd11 Improve unit handling in recorder (#153941) 2025-10-09 10:29:42 +02:00
David Rapan
2eba650064 Mark Shelly docs-troubleshooting as done (#154066) 2025-10-09 11:22:32 +03:00
Christopher Fenner
de4adb8855 Make sensor names translatable in OpenWeatherMap integration (#153872) 2025-10-09 09:47:25 +02:00
Joost Lekkerkerker
1d86c03b02 Migrate Nederlandse Spoorwegen sensor to timestamp (#154011) 2025-10-09 09:25:11 +02:00
Klaas Schoute
77fb1036cc Bump autarco to v3.2.0 (#154039) 2025-10-09 01:13:57 +03:00
Shay Levy
b15b4e4888 Fix Shelly virtual components roles migration (#153987) 2025-10-09 00:06:32 +03:00
puddly
dddf6d5f1a Add new ZBT-2 VID:PID pair for discovery (#154036) 2025-10-08 15:59:49 -05:00
Abílio Costa
66fb5f4d95 Simplify firing of trigger actions (#152772)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-10-08 21:40:20 +01:00
hanwg
42a9d5d4e3 Add webhook tests for Telegram bot (#153998) 2025-10-08 20:58:15 +02:00
Maciej Bieniek
93fa162913 Update IQS for IMGW-PIB integration (#153870) 2025-10-08 20:30:05 +02:00
Maciej Bieniek
c432b1c8da Add entities for Shely cury component (#153918) 2025-10-08 20:26:29 +02:00
Artur Pragacz
00955b8e6a Fix empty llm api list in chat log (#153996) 2025-10-08 10:39:56 -05:00
Erik Montnemery
045b9d7f01 Correct homeassistant.helpers.trigger._trigger_action_wrapper (#153983) 2025-10-08 17:33:44 +02:00
Aaron Bach
438c4c7871 Limit SimpliSafe websocket connection attempts during startup (#153853)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-08 16:32:17 +02:00
Thomas D
abc360460c Add diagnostics to Volvo integration (#153997) 2025-10-08 16:25:33 +02:00
HarvsG
26437bb253 Adds ConfigFlow for London Underground (#152050)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-08 16:17:34 +02:00
epenet
56d953ac1e Use contants in climate set_temperature (#154008) 2025-10-08 16:15:01 +02:00
Joost Lekkerkerker
fe4eb8766d Don't mark ZHA coordinator as via_device with itself (#154004) 2025-10-08 16:05:54 +02:00
Mark Adkins
2d9f14c401 Add 3rd maintainer to sharkiq (#153961) 2025-10-08 15:17:52 +02:00
dependabot[bot]
7b6ccb07fd Bump github/codeql-action from 3.30.6 to 4.30.7 (#153979)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 13:42:25 +02:00
Shay Levy
2ba5728060 Enable Shelly binary input sensors by default (#154001) 2025-10-08 14:41:53 +03:00
epenet
b5f163cc85 Update Tuya fixture for product ID IAYz2WK1th0cMLmL (#154000) 2025-10-08 13:28:11 +02:00
Marc Mueller
65540a3e0b Update mypy dev to 1.19.0a4 (#153995) 2025-10-08 13:24:54 +02:00
Erwin Douna
cbf1b39edb Portainer add sensor platform (#153059)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2025-10-08 11:02:20 +02:00
G Johansson
142daf5e49 Call async_track_template_result with template without hass now fails (#153473)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-08 10:14:51 +02:00
Erik Montnemery
8bd0ff7cca Replace has_mean with mean_type in mill external statistics (#153985) 2025-10-08 09:52:07 +02:00
Erik Montnemery
ac676e12f6 Remove has_mean from suez_water external statistics (#153986) 2025-10-08 09:51:44 +02:00
Glenn Vandeuren (aka Iondependent)
c0ac3292cd FIx brightness always 100% when toggling the light (#153765)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-10-08 09:48:41 +02:00
Denis Shulyaka
80fd07c128 Add GPT-5 Pro and GPT-5 Codex support (#153936) 2025-10-08 09:48:07 +02:00
Michael Davie
3701d8859a Bump env-canada to 0.11.3 (#153967) 2025-10-08 09:40:55 +02:00
Jesse Hills
6dd26bae88 Bump aioesphomeapi to 41.13.0 (#153974) 2025-10-07 18:28:56 -10:00
Dave T
1a0abe296c Remove deprecated conductivity constants (#153942) 2025-10-07 23:20:36 +01:00
G Johansson
de6c61a4ab Bump psutil 7.1.0 (#153954) 2025-10-07 23:16:49 +01:00
Glenn Vandeuren (aka Iondependent)
33c677596e Update nhc to 0.6.1 (#153962) 2025-10-07 23:16:04 +01:00
peetersch
e9b4b8e99b Modbus Fix message_wait_milliseconds is no longer applied (#153709) 2025-10-07 23:38:05 +02:00
Maciej Bieniek
0525c04c42 Fix update interval for AccuWeather hourly forecast (#153957) 2025-10-07 23:25:04 +02:00
Shay Levy
d57b502551 Migrate Shelly virtual button platfrom unique IDs to include roles (#153865) 2025-10-07 23:01:30 +03:00
G Johansson
9fb708baf4 Bump holidays to 0.82 (#153952) 2025-10-07 23:00:38 +03:00
Josef Zweck
abdf24b7a0 Bump pylamarzocco to 2.1.2 (#153950) 2025-10-07 22:07:39 +03:00
TheJulianJES
29bfbd27bb Do not auto-set up ZHA zeroconf discoveries during onboarding (#153914) 2025-10-07 15:02:02 -04:00
starkillerOG
224553f8d9 Reverse Motion Blinds tilt direction (#149777)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-07 18:50:39 +01:00
mbo18
7c9f6a061f Add icons for SmartThings climate presets (#153929) 2025-10-07 19:15:15 +02:00
Marc Mueller
8e115d4685 Update pydantic to 2.12.0 (#153937) 2025-10-07 17:50:40 +01:00
Denis Shulyaka
00c189844f Bump openai to 2.2.0 (#153926) 2025-10-07 17:41:52 +01:00
Ståle Storø Hauknes
4587c286bb Add new sensors for Airthings Wave Enhance (#153879) 2025-10-07 17:44:30 +02:00
Artur Pragacz
b46097a7fc Move agent functionality from http (#153917) 2025-10-07 14:49:11 +02:00
mbo18
299cb6a2ff Change smart preset name to smart saver (#153916) 2025-10-07 14:11:00 +02:00
Erik Montnemery
1b7b91b328 Remove unused test fixtures from nintendo_parental (#153894) 2025-10-07 14:03:29 +02:00
Maciej Bieniek
01a1480ebd Use aioshelly methods for switches (#153746) 2025-10-07 13:28:58 +02:00
Jordan Harvey
26b8abb118 Bump pynintendoparental to 1.1.1 (#153874) 2025-10-07 13:28:08 +02:00
FMKaiba
53d1bbb530 Add support for gas detector status to SmartThings (#153831)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-10-07 12:56:53 +02:00
Tom Matheussen
a3ef55274e Add missing translation string for Satel Integra subentry type (#153905) 2025-10-07 12:18:51 +02:00
Joost Lekkerkerker
2034915457 Add fixture to SmartThings (#153902) 2025-10-07 12:13:12 +02:00
Joost Lekkerkerker
9e46d7964a Update SmartThings comments (#153903) 2025-10-07 11:46:44 +02:00
Maciej Bieniek
f9828a227b Bump aioshelly to version 13.12.0 (#153899) 2025-10-07 11:43:56 +02:00
Simone Chemelli
3341fa5f33 Code optimization for Comelit SimpleHome (#153029) 2025-10-07 10:31:44 +01:00
Christopher Fenner
e38ae47e76 Add language and location selector to OpenWeatherMap config flow (#153645)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-07 11:06:04 +02:00
Christopher Fenner
934c0e3c4c fix typo in icon assignment of AccuWeather integration (#153890) 2025-10-07 10:15:01 +02:00
Simone Chemelli
994a6ae7ed Fix restore cover state for Comelit SimpleHome (#153887) 2025-10-07 09:06:55 +02:00
Christopher Fenner
cdbe93c289 Set display precision for sensors in OpenWeatherMap integration (#153858) 2025-10-07 08:58:18 +02:00
Marc Mueller
56f90e4d96 Update pytest warnings filter (#153881) 2025-10-07 09:55:50 +03:00
TheJulianJES
34977abfec Remove Z-Wave JS voltage sensor overriding suggested precision (#153882) 2025-10-07 08:44:53 +02:00
Marc Mueller
5622103eb1 Fix nintendo_parental RuntimeWarning in tests (#153884) 2025-10-07 08:44:34 +02:00
Artur Pragacz
b9a1ab4a44 Clean up core references in conversation (#153880) 2025-10-07 00:46:47 +02:00
David Rapan
18997833c4 Shelly's power sensors naming paradigm standardization (#153822)
Signed-off-by: David Rapan <david@rapan.cz>
2025-10-07 01:32:44 +03:00
David Rapan
f99b194afc Shelly's current sensors naming paradigm standardization (#153827)
Signed-off-by: David Rapan <david@rapan.cz>
2025-10-07 01:32:25 +03:00
Dave T
566a347da7 Remove deprecated alarm panel constants (#153876) 2025-10-06 23:03:29 +01:00
Shay Levy
881306f6a4 Migrate Shelly virtual component unique IDs to include roles (#153844) 2025-10-07 00:50:47 +03:00
Marc Mueller
f63504af01 Update aiohttp to 3.13.0 (#153875) 2025-10-06 15:47:33 -05:00
derytive
d140b82a70 Add plate_count for Miele KM7575 (#153868) 2025-10-06 21:53:09 +02:00
Allen Porter
681211b1a5 Add Model Context Protocol support for OAuth scopes (#153150) 2025-10-06 15:32:42 -04:00
Joost Lekkerkerker
6c8b1f3618 Catch update exception in AirGradient (#153828) 2025-10-06 21:31:55 +02:00
Abílio Costa
d341065c34 Replace inner function with lambda in Idasen Desk (#153862) 2025-10-06 21:25:10 +02:00
G Johansson
81b1346080 Handle timeout errors gracefully in Nord Pool services (#153856) 2025-10-06 22:15:38 +03:00
483 changed files with 18103 additions and 4050 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 8
CACHE_VERSION: 9
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11"
@@ -525,7 +525,7 @@ jobs:
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=21.3.1" setuptools wheel
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt
python -m script.gen_requirements_all ci
uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
@@ -741,7 +741,7 @@ jobs:
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3)
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
echo "version=$mypy_version" >> $GITHUB_OUTPUT
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
category: "/language:python"

View File

@@ -221,6 +221,7 @@ homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
homeassistant.components.geocaching.*
homeassistant.components.gios.*
homeassistant.components.github.*
homeassistant.components.glances.*
homeassistant.components.go2rtc.*
homeassistant.components.goalzero.*

16
CODEOWNERS generated
View File

@@ -762,8 +762,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
/homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
@@ -1065,8 +1065,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental/ @pantherale0
/tests/components/nintendo_parental/ @pantherale0
/homeassistant/components/nintendo_parental_controls/ @pantherale0
/tests/components/nintendo_parental_controls/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1413,8 +1413,8 @@ build.json @home-assistant/supervisor
/tests/components/sfr_box/ @epenet
/homeassistant/components/sftp_storage/ @maretodoric
/tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
/homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
@@ -1479,8 +1479,8 @@ build.json @home-assistant/supervisor
/tests/components/snoo/ @Lash-L
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck @bdraco
/tests/components/solaredge/ @frenck @bdraco
/homeassistant/components/solaredge/ @frenck @bdraco @tronikos
/tests/components/solaredge/ @frenck @bdraco @tronikos
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli

View File

@@ -34,9 +34,11 @@ WORKDIR /usr/src
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN uv python install 3.13.2
USER vscode
ENV UV_PYTHON=3.13.2
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

View File

@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30)

View File

@@ -1,6 +1,9 @@
{
"entity": {
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
},
"cloud_ceiling": {
"default": "mdi:weather-fog"
},
@@ -34,9 +37,6 @@
"thunderstorm_probability_night": {
"default": "mdi:weather-lightning"
},
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": {
"default": "mdi:tree-outline"
},

View File

@@ -71,7 +71,14 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders={
"api_key_url": "https://opendata.aemet.es/centrodedescargas/altaUsuario"
},
)
@staticmethod
@callback

View File

@@ -14,7 +14,7 @@
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "Name of the integration"
},
"description": "To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario"
"description": "To generate API key go to {api_key_url}"
}
}
},

View File

@@ -30,6 +30,7 @@ generate_data:
media:
accept:
- "*"
multiple: true
generate_image:
fields:
task_name:
@@ -57,3 +58,4 @@ generate_image:
media:
accept:
- "*"
multiple: true

View File

@@ -1,7 +1,9 @@
"""Airgradient Update platform."""
from datetime import timedelta
import logging
from airgradient import AirGradientConnectionError
from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
@@ -13,6 +15,7 @@ from .entity import AirGradientEntity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@@ -31,6 +34,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Representation of Airgradient Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_server_unreachable_logged = False
def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity."""
@@ -47,10 +51,27 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Return the installed version of the entity."""
return self.coordinator.data.measures.firmware_version
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._attr_available
async def async_update(self) -> None:
"""Update the entity."""
self._attr_latest_version = (
await self.coordinator.client.get_latest_firmware_version(
self.coordinator.serial_number
try:
self._attr_latest_version = (
await self.coordinator.client.get_latest_firmware_version(
self.coordinator.serial_number
)
)
)
except AirGradientConnectionError:
self._attr_latest_version = None
self._attr_available = False
if not self._server_unreachable_logged:
_LOGGER.error(
"Unable to connect to AirGradient server to check for updates"
)
self._server_unreachable_logged = True
else:
self._server_unreachable_logged = False
self._attr_available = True

View File

@@ -18,6 +18,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
}
class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Airly."""
@@ -85,6 +89,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
description_placeholders=DESCRIPTION_PLACEHOLDERS,
)

View File

@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "To generate API key go to https://developer.airly.eu/register",
"description": "To generate API key go to {developer_registration_url}",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]",

View File

@@ -16,10 +16,12 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
Platform,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
@@ -112,6 +114,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"noise": SensorEntityDescription(
key="noise",
translation_key="ambient_noise",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
PARALLEL_UPDATES = 0

View File

@@ -41,6 +41,9 @@
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"ambient_noise": {
"name": "Ambient noise"
}
}
}

View File

@@ -2,10 +2,9 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any, Final, final
from typing import Any, Final, final
from propcache.api import cached_property
import voluptuous as vol
@@ -28,8 +27,6 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -149,68 +146,11 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
)
_alarm_control_panel_option_default_code: str | None = None
__alarm_legacy_state: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if any(method in cls.__dict__ for method in ("_attr_state", "state")):
# Integrations should use the 'alarm_state' property instead of
# setting the state directly.
cls.__alarm_legacy_state = True
def __setattr__(self, name: str, value: Any, /) -> None:
"""Set attribute.
Deprecation warning if setting '_attr_state' directly
unless already reported.
"""
if name == "_attr_state":
self._report_deprecated_alarm_state_handling()
return super().__setattr__(name, value)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.__alarm_legacy_state:
self._report_deprecated_alarm_state_handling()
@callback
def _report_deprecated_alarm_state_handling(self) -> None:
"""Report on deprecated handling of alarm state.
Integrations should implement alarm_state instead of using state directly.
"""
report_usage(
"is setting state directly."
f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'"
" property and return its state using the AlarmControlPanelState enum",
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2025.11",
integration_domain=self.platform.platform_name if self.platform else None,
exclude_integrations={DOMAIN},
)
@final
@property
def state(self) -> str | None:
"""Return the current state."""
if (alarm_state := self.alarm_state) is not None:
return alarm_state
if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None
return self.alarm_state
@cached_property
def alarm_state(self) -> AlarmControlPanelState | None:

View File

@@ -1472,10 +1472,10 @@ class AlexaModeController(AlexaCapability):
# Return state instead of position when using ModeController.
mode = self.entity.state
if mode in (
cover.STATE_OPEN,
cover.STATE_OPENING,
cover.STATE_CLOSED,
cover.STATE_CLOSING,
cover.CoverState.OPEN,
cover.CoverState.OPENING,
cover.CoverState.CLOSED,
cover.CoverState.CLOSING,
STATE_UNKNOWN,
):
return f"{cover.ATTR_POSITION}.{mode}"
@@ -1594,11 +1594,11 @@ class AlexaModeController(AlexaCapability):
["Position", AlexaGlobalCatalog.SETTING_OPENING], False
)
self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}",
[AlexaGlobalCatalog.VALUE_OPEN],
)
self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}",
[AlexaGlobalCatalog.VALUE_CLOSE],
)
self._resource.add_mode(
@@ -1651,22 +1651,22 @@ class AlexaModeController(AlexaCapability):
raise_labels.append(AlexaSemantics.ACTION_OPEN)
self._semantics.add_states_to_value(
[AlexaSemantics.STATES_CLOSED],
f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}",
)
self._semantics.add_states_to_value(
[AlexaSemantics.STATES_OPEN],
f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}",
)
self._semantics.add_action_to_directive(
lower_labels,
"SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"},
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}"},
)
self._semantics.add_action_to_directive(
raise_labels,
"SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"},
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}"},
)
return self._semantics.serialize_semantics()

View File

@@ -1261,9 +1261,9 @@ async def async_api_set_mode(
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
position = mode.split(".")[1]
if position == cover.STATE_CLOSED:
if position == cover.CoverState.CLOSED:
service = cover.SERVICE_CLOSE_COVER
elif position == cover.STATE_OPEN:
elif position == cover.CoverState.OPEN:
service = cover.SERVICE_OPEN_COVER
elif position == "custom":
service = cover.SERVICE_STOP_COVER

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.2.9"]
"requirements": ["aioamazondevices==6.4.3"]
}

View File

@@ -4,12 +4,15 @@ from __future__ import annotations
from collections.abc import Mapping
from functools import partial
import json
import logging
from typing import Any, cast
import anthropic
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
@@ -18,7 +21,13 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
@@ -37,12 +46,23 @@ from .const import (
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
RECOMMENDED_WEB_SEARCH,
RECOMMENDED_WEB_SEARCH_MAX_USES,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
WEB_SEARCH_UNSUPPORTED_MODELS,
)
_LOGGER = logging.getLogger(__name__)
@@ -168,6 +188,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH):
model = user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
if model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
errors[CONF_WEB_SEARCH] = "web_search_unsupported_model"
elif user_input.get(
CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION
):
user_input.update(await self._get_location_data())
if not errors:
if self._is_new:
@@ -215,6 +243,68 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors=errors or None,
)
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}
zone_home = self.hass.states.get(ENTITY_ID_HOME)
if zone_home is not None:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
location_schema = vol.Schema(
{
vol.Optional(
CONF_WEB_SEARCH_CITY,
description="Free text input for the city, e.g. `San Francisco`",
): str,
vol.Optional(
CONF_WEB_SEARCH_REGION,
description="Free text input for the region, e.g. `California`",
): str,
}
)
response = await client.messages.create(
model=RECOMMENDED_CHAT_MODEL,
messages=[
{
"role": "user",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[ATTR_LATITUDE]},"
f" {zone_home.attributes[ATTR_LONGITUDE]})? Please respond "
"only with a JSON object using the following schema:\n"
f"{convert(location_schema)}",
},
{
"role": "assistant",
"content": "{", # hints the model to skip any preamble
},
],
max_tokens=RECOMMENDED_MAX_TOKENS,
)
_LOGGER.debug("Model response: %s", response.content)
location_data = location_schema(
json.loads(
"{"
+ "".join(
block.text
for block in response.content
if isinstance(block, anthropic.types.TextBlock)
)
)
or {}
)
if self.hass.config.country:
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone
_LOGGER.debug("Location data: %s", location_data)
return location_data
async_step_user = async_step_set_options
async_step_reconfigure = async_step_set_options
@@ -273,6 +363,18 @@ def anthropic_config_option_schema(
CONF_THINKING_BUDGET,
default=RECOMMENDED_THINKING_BUDGET,
): int,
vol.Optional(
CONF_WEB_SEARCH,
default=RECOMMENDED_WEB_SEARCH,
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=RECOMMENDED_WEB_SEARCH_MAX_USES,
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
): bool,
}
)
return schema

View File

@@ -18,9 +18,26 @@ RECOMMENDED_TEMPERATURE = 1.0
CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024
CONF_WEB_SEARCH = "web_search"
RECOMMENDED_WEB_SEARCH = False
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
RECOMMENDED_WEB_SEARCH_MAX_USES = 5
CONF_WEB_SEARCH_CITY = "city"
CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
NON_THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku
"claude-3-opus",
"claude-3-haiku",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]

View File

@@ -1,12 +1,17 @@
"""Base entity for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field
import json
from typing import Any
import anthropic
from anthropic import AsyncStream
from anthropic.types import (
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
ContentBlockParam,
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
@@ -16,11 +21,16 @@ from anthropic.types import (
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
ServerToolUseBlock,
ServerToolUseBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextCitation,
TextCitationParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
@@ -29,9 +39,15 @@ from anthropic.types import (
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUnionParam,
ToolUseBlock,
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchToolRequestErrorParam,
WebSearchToolResultBlock,
WebSearchToolResultBlockParam,
WebSearchToolResultError,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from voluptuous_openapi import convert
@@ -48,6 +64,13 @@ from .const import (
CONF_MAX_TOKENS,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
@@ -73,6 +96,69 @@ def _format_tool(
)
@dataclass(slots=True)
class CitationDetails:
"""Citation details for a content part."""
index: int = 0
"""Start position of the text."""
length: int = 0
"""Length of the relevant data."""
citations: list[TextCitationParam] = field(default_factory=list)
"""Citations for the content part."""
@dataclass(slots=True)
class ContentDetails:
"""Native data for AssistantContent."""
citation_details: list[CitationDetails] = field(default_factory=list)
def has_content(self) -> bool:
"""Check if there is any content."""
return any(detail.length > 0 for detail in self.citation_details)
def has_citations(self) -> bool:
"""Check if there are any citations."""
return any(detail.citations for detail in self.citation_details)
def add_citation_detail(self) -> None:
"""Add a new citation detail."""
if not self.citation_details or self.citation_details[-1].length > 0:
self.citation_details.append(
CitationDetails(
index=self.citation_details[-1].index
+ self.citation_details[-1].length
if self.citation_details
else 0
)
)
def add_citation(self, citation: TextCitation) -> None:
"""Add a citation to the current detail."""
if not self.citation_details:
self.citation_details.append(CitationDetails())
citation_param: TextCitationParam | None = None
if isinstance(citation, CitationsWebSearchResultLocation):
citation_param = CitationWebSearchResultLocationParam(
type="web_search_result_location",
title=citation.title,
url=citation.url,
cited_text=citation.cited_text,
encrypted_index=citation.encrypted_index,
)
if citation_param:
self.citation_details[-1].citations.append(citation_param)
def delete_empty(self) -> None:
"""Delete empty citation details."""
self.citation_details = [
detail for detail in self.citation_details if detail.citations
]
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
@@ -81,15 +167,31 @@ def _convert_content(
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":
if content.tool_name == "web_search":
tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam(
type="web_search_tool_result",
tool_use_id=content.tool_call_id,
content=content.tool_result["content"]
if "content" in content.tool_result
else WebSearchToolRequestErrorParam(
type="web_search_tool_result_error",
error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item]
),
)
external_tool = True
else:
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
external_tool = False
if not messages or messages[-1]["role"] != (
"assistant" if external_tool else "user"
):
messages.append(
MessageParam(
role="user",
role="assistant" if external_tool else "user",
content=[tool_result_block],
)
)
@@ -151,13 +253,56 @@ def _convert_content(
redacted_thinking_block
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
current_index = 0
for detail in (
content.native.citation_details
if isinstance(content.native, ContentDetails)
else [CitationDetails(length=len(content.content))]
):
if detail.index > current_index:
# Add text block for any text without citations
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=content.content[current_index : detail.index],
)
)
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=content.content[
detail.index : detail.index + detail.length
],
citations=detail.citations,
)
if detail.citations
else TextBlockParam(
type="text",
text=content.content[
detail.index : detail.index + detail.length
],
)
)
current_index = detail.index + detail.length
if current_index < len(content.content):
# Add text block for any remaining text without citations
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=content.content[current_index:],
)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
ServerToolUseBlockParam(
type="server_tool_use",
id=tool_call.id,
name="web_search",
input=tool_call.tool_args,
)
if tool_call.external and tool_call.tool_name == "web_search"
else ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
@@ -173,10 +318,12 @@ def _convert_content(
return messages
async def _transform_stream(
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
@@ -209,11 +356,13 @@ async def _transform_stream(
if stream is None:
raise TypeError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | None = None
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None
has_content = False
has_native = False
first_block: bool
async for response in stream:
LOGGER.debug("Received response: %s", response)
@@ -222,6 +371,7 @@ async def _transform_stream(
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
input_usage = response.message.usage
first_block = True
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_tool_block = ToolUseBlockParam(
@@ -232,17 +382,37 @@ async def _transform_stream(
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
if has_content:
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block
or (
not content_details.has_citations()
and response.content_block.citations is None
and content_details.has_content()
)
):
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"}
has_native = False
has_content = True
first_block = False
content_details.add_citation_detail()
if response.content_block.text:
content_details.citation_details[-1].length += len(
response.content_block.text
)
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if has_native:
if first_block or has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
has_content = False
first_block = False
elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
@@ -250,15 +420,60 @@ async def _transform_stream(
"responses"
)
if has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
has_content = False
first_block = False
yield {"native": response.content_block}
has_native = True
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {
"role": "tool_result",
"tool_call_id": response.content_block.tool_use_id,
"tool_name": "web_search",
"tool_result": {
"type": "web_search_tool_result_error",
"error_code": response.content_block.content.error_code,
}
if isinstance(
response.content_block.content, WebSearchToolResultError
)
else {
"content": [
{
"type": "web_search_result",
"encrypted_content": block.encrypted_content,
"page_age": block.page_age,
"title": block.title,
"url": block.url,
}
for block in response.content_block.content
]
},
}
first_block = True
elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
yield {"thinking_content": response.delta.thinking}
@@ -271,6 +486,8 @@ async def _transform_stream(
)
}
has_native = True
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None:
tool_args = json.loads(current_tool_args) if current_tool_args else {}
@@ -281,6 +498,7 @@ async def _transform_stream(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=tool_args,
external=current_tool_block["type"] == "server_tool_use",
)
]
}
@@ -290,6 +508,12 @@ async def _transform_stream(
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 content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
def _create_token_stats(
@@ -337,21 +561,11 @@ class AnthropicBaseLLMEntity(Entity):
"""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)
model_args = MessageCreateParamsStreaming(
@@ -361,8 +575,8 @@ class AnthropicBaseLLMEntity(Entity):
system=system.content,
stream=True,
)
if tools:
model_args["tools"] = tools
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
if (
not model.startswith(tuple(NON_THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
@@ -376,6 +590,34 @@ class AnthropicBaseLLMEntity(Entity):
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
tools: list[ToolUnionParam] = []
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 = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"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, ""),
}
tools.append(web_search)
if tools:
model_args["tools"] = tools
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:

View File

@@ -35,11 +35,17 @@
"temperature": "Temperature",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"recommended": "Recommended model settings",
"thinking_budget_tokens": "Thinking budget"
"thinking_budget": "Thinking budget",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches",
"user_location": "Include home location"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response",
"user_location": "Localize search results based on home location"
}
}
},
@@ -48,7 +54,8 @@
"entry_not_loaded": "Cannot add things while the configuration is disabled."
},
"error": {
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget.",
"web_search_unsupported_model": "Web search is not supported by the selected model. Please choose a compatible model or disable web search."
}
}
}

View File

@@ -7,6 +7,8 @@ from typing import Any
from pyaprilaire.const import Attribute
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
FAN_AUTO,
FAN_ON,
PRESET_AWAY,
@@ -16,7 +18,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -232,15 +239,15 @@ class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
cool_setpoint = 0
heat_setpoint = 0
if temperature := kwargs.get("temperature"):
if temperature := kwargs.get(ATTR_TEMPERATURE):
if self.coordinator.data.get(Attribute.MODE) == 3:
cool_setpoint = temperature
else:
heat_setpoint = temperature
else:
if target_temp_low := kwargs.get("target_temp_low"):
if target_temp_low := kwargs.get(ATTR_TARGET_TEMP_LOW):
heat_setpoint = target_temp_low
if target_temp_high := kwargs.get("target_temp_high"):
if target_temp_high := kwargs.get(ATTR_TARGET_TEMP_HIGH):
cool_setpoint = target_temp_high
if cool_setpoint == 0 and heat_setpoint == 0:

View File

@@ -36,11 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
raise ConfigEntryAuthFailed("Migration to OAuth required")
session = async_create_august_clientsession(hass)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
)
except ValueError as err:
raise ConfigEntryNotReady("OAuth implementation not available") from err
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try:

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autarco",
"iot_class": "cloud_polling",
"requirements": ["autarco==3.1.0"]
"requirements": ["autarco==3.2.0"]
}

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"requirements": ["brother==5.1.0"],
"requirements": ["brother==5.1.1"],
"zeroconf": [
{
"type": "_printer._tcp.local.",

View File

@@ -7,12 +7,14 @@ from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -208,24 +210,24 @@ class BryantEvolutionClimate(ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if kwargs.get("target_temp_high"):
temp = int(kwargs["target_temp_high"])
if value := kwargs.get(ATTR_TARGET_TEMP_HIGH):
temp = int(value)
if not await self._client.set_cooling_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
)
self._attr_target_temperature_high = temp
if kwargs.get("target_temp_low"):
temp = int(kwargs["target_temp_low"])
if value := kwargs.get(ATTR_TARGET_TEMP_LOW):
temp = int(value)
if not await self._client.set_heating_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
)
self._attr_target_temperature_low = temp
if kwargs.get("temperature"):
temp = int(kwargs["temperature"])
if value := kwargs.get(ATTR_TEMPERATURE):
temp = int(value)
fn = (
self._client.set_heating_setpoint
if self.hvac_mode == HVACMode.HEAT

View File

@@ -169,7 +169,7 @@ class CalendarEventListener:
def __init__(
self,
hass: HomeAssistant,
job: HassJob[..., Coroutine[Any, Any, None]],
job: HassJob[..., Coroutine[Any, Any, None] | Any],
trigger_data: dict[str, Any],
fetcher: QueuedEventFetcher,
) -> None:

View File

@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/citybikes",
"iot_class": "cloud_polling",
"quality_scale": "legacy"
"quality_scale": "legacy",
"requirements": ["python-citybikes==0.3.3"]
}

View File

@@ -5,8 +5,11 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
import sys
import aiohttp
from citybikes import __version__ as CITYBIKES_CLIENT_VERSION
from citybikes.asyncio import Client as CitybikesClient
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -15,21 +18,18 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.const import (
ATTR_ID,
ATTR_LATITUDE,
ATTR_LOCATION,
ATTR_LONGITUDE,
ATTR_NAME,
APPLICATION_NAME,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_RADIUS,
EVENT_HOMEASSISTANT_CLOSE,
UnitOfLength,
__version__,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
@@ -40,31 +40,33 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
_LOGGER = logging.getLogger(__name__)
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_EXTRA = "extra"
ATTR_FREE_BIKES = "free_bikes"
ATTR_NETWORK = "network"
ATTR_NETWORKS_LIST = "networks"
ATTR_STATIONS_LIST = "stations"
ATTR_TIMESTAMP = "timestamp"
HA_USER_AGENT = (
f"{APPLICATION_NAME}/{__version__} "
f"python-citybikes/{CITYBIKES_CLIENT_VERSION} "
f"Python/{sys.version_info[0]}.{sys.version_info[1]}"
)
ATTR_UID = "uid"
ATTR_LATITUDE = "latitude"
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_TIMESTAMP = "timestamp"
CONF_NETWORK = "network"
CONF_STATIONS_LIST = "stations"
DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}"
PLATFORM = "citybikes"
MONITORED_NETWORKS = "monitored-networks"
DATA_CLIENT = "client"
NETWORKS_URI = "v2/networks"
REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
STATIONS_URI = "v2/networks/{uid}?fields=network.stations"
CITYBIKES_ATTRIBUTION = (
"Information provided by the CityBikes Project (https://citybik.es/#about)"
)
@@ -87,72 +89,6 @@ PLATFORM_SCHEMA = vol.All(
),
)
NETWORK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ID): cv.string,
vol.Required(ATTR_NAME): cv.string,
vol.Required(ATTR_LOCATION): vol.Schema(
{
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
},
extra=vol.REMOVE_EXTRA,
),
},
extra=vol.REMOVE_EXTRA,
)
NETWORKS_RESPONSE_SCHEMA = vol.Schema(
{vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA]}
)
STATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FREE_BIKES): cv.positive_int,
vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None),
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Required(ATTR_ID): cv.string,
vol.Required(ATTR_NAME): cv.string,
vol.Required(ATTR_TIMESTAMP): cv.string,
vol.Optional(ATTR_EXTRA): vol.Schema(
{vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA
),
},
extra=vol.REMOVE_EXTRA,
)
STATIONS_RESPONSE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_NETWORK): vol.Schema(
{vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]}, extra=vol.REMOVE_EXTRA
)
}
)
class CityBikesRequestError(Exception):
"""Error to indicate a CityBikes API request has failed."""
async def async_citybikes_request(hass, uri, schema):
"""Perform a request to CityBikes API endpoint, and parse the response."""
try:
session = async_get_clientsession(hass)
async with asyncio.timeout(REQUEST_TIMEOUT):
req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
json_response = await req.json()
return schema(json_response)
except (TimeoutError, aiohttp.ClientError):
_LOGGER.error("Could not connect to CityBikes API endpoint")
except ValueError:
_LOGGER.error("Received non-JSON data from CityBikes API endpoint")
except vol.Invalid as err:
_LOGGER.error("Received unexpected JSON from CityBikes API endpoint: %s", err)
raise CityBikesRequestError
async def async_setup_platform(
hass: HomeAssistant,
@@ -175,6 +111,14 @@ async def async_setup_platform(
radius, UnitOfLength.FEET, UnitOfLength.METERS
)
client = CitybikesClient(user_agent=HA_USER_AGENT, timeout=REQUEST_TIMEOUT)
hass.data[PLATFORM][DATA_CLIENT] = client
async def _async_close_client(event):
await client.close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client)
# Create a single instance of CityBikesNetworks.
networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass))
@@ -194,10 +138,10 @@ async def async_setup_platform(
devices = []
for station in network.stations:
dist = location_util.distance(
latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE]
latitude, longitude, station.latitude, station.longitude
)
station_id = station[ATTR_ID]
station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, ""))
station_id = station.id
station_uid = str(station.extra.get(ATTR_UID, ""))
if radius > dist or stations_list.intersection((station_id, station_uid)):
if name:
@@ -216,6 +160,7 @@ class CityBikesNetworks:
def __init__(self, hass):
"""Initialize the networks instance."""
self.hass = hass
self.client = hass.data[PLATFORM][DATA_CLIENT]
self.networks = None
self.networks_loading = asyncio.Condition()
@@ -224,24 +169,21 @@ class CityBikesNetworks:
try:
await self.networks_loading.acquire()
if self.networks is None:
networks = await async_citybikes_request(
self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA
)
self.networks = networks[ATTR_NETWORKS_LIST]
except CityBikesRequestError as err:
self.networks = await self.client.networks.fetch()
except aiohttp.ClientError as err:
raise PlatformNotReady from err
else:
result = None
minimum_dist = None
for network in self.networks:
network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
network_latitude = network.location.latitude
network_longitude = network.location.longitude
dist = location_util.distance(
latitude, longitude, network_latitude, network_longitude
)
if minimum_dist is None or dist < minimum_dist:
minimum_dist = dist
result = network[ATTR_ID]
result = network.id
return result
finally:
@@ -257,22 +199,20 @@ class CityBikesNetwork:
self.network_id = network_id
self.stations = []
self.ready = asyncio.Event()
self.client = hass.data[PLATFORM][DATA_CLIENT]
async def async_refresh(self, now=None):
"""Refresh the state of the network."""
try:
network = await async_citybikes_request(
self.hass,
STATIONS_URI.format(uid=self.network_id),
STATIONS_RESPONSE_SCHEMA,
)
self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST]
self.ready.set()
except CityBikesRequestError as err:
if now is not None:
self.ready.clear()
else:
network = await self.client.network(uid=self.network_id).fetch()
except aiohttp.ClientError as err:
if now is None:
raise PlatformNotReady from err
self.ready.clear()
return
self.stations = network.stations
self.ready.set()
class CityBikesStation(SensorEntity):
@@ -290,16 +230,13 @@ class CityBikesStation(SensorEntity):
async def async_update(self) -> None:
"""Update station state."""
for station in self._network.stations:
if station[ATTR_ID] == self._station_id:
station_data = station
break
self._attr_name = station_data.get(ATTR_NAME)
self._attr_native_value = station_data.get(ATTR_FREE_BIKES)
station = next(s for s in self._network.stations if s.id == self._station_id)
self._attr_name = station.name
self._attr_native_value = station.free_bikes
self._attr_extra_state_attributes = {
ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID),
ATTR_LATITUDE: station_data.get(ATTR_LATITUDE),
ATTR_LONGITUDE: station_data.get(ATTR_LONGITUDE),
ATTR_EMPTY_SLOTS: station_data.get(ATTR_EMPTY_SLOTS),
ATTR_TIMESTAMP: station_data.get(ATTR_TIMESTAMP),
ATTR_UID: station.extra.get(ATTR_UID),
ATTR_LATITUDE: station.latitude,
ATTR_LONGITUDE: station.longitude,
ATTR_EMPTY_SLOTS: station.empty_slots,
ATTR_TIMESTAMP: station.timestamp,
}

View File

@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -29,23 +30,19 @@ async def async_setup_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
known_devices: set[int] = set()
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
def _check_device() -> None:
current_devices = set(coordinator.data["alarm_zones"])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
class ComelitVedoBinarySensorEntity(

View File

@@ -7,14 +7,14 @@ from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
from .utils import bridge_api_call
from .utils import DeviceType, bridge_api_call, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -29,21 +29,19 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set()
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
def _check_device() -> None:
current_devices = set(coordinator.data[COVER])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[COVER].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, COVER)
)
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
@@ -62,7 +60,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
super().__init__(coordinator, device, config_entry_entry_id)
# Device doesn't provide a status so we assume UNKNOWN at first startup
self._last_action: int | None = None
self._last_state: str | None = None
def _current_action(self, action: str) -> bool:
"""Return the current cover action."""
@@ -98,7 +95,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
@bridge_api_call
async def _cover_set_state(self, action: int, state: int) -> None:
"""Set desired cover state."""
self._last_state = self.state
await self.coordinator.api.set_device_status(COVER, self._device.index, action)
self.coordinator.data[COVER][self._device.index].status = state
self.async_write_ha_state()
@@ -124,5 +120,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
self._last_state = last_state.state
if (state := await self.async_get_last_state()) is not None:
if state.state == CoverState.CLOSED:
self._last_action = STATE_COVER.index(CoverState.CLOSING)
if state.state == CoverState.OPEN:
self._last_action = STATE_COVER.index(CoverState.OPENING)
self._attr_is_closed = state.state == CoverState.CLOSED

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
from .utils import bridge_api_call
from .utils import DeviceType, bridge_api_call, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -27,21 +27,19 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set()
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
def _check_device() -> None:
current_devices = set(coordinator.data[LIGHT])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, LIGHT)
)
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):

View File

@@ -20,6 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .entity import ComelitBridgeBaseEntity
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -65,24 +66,22 @@ async def async_setup_bridge_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data[OTHER])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_BRIDGE_TYPES
for device in coordinator.data[OTHER].values()
if device.index in new_devices
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_BRIDGE_TYPES
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, OTHER)
)
async def async_setup_vedo_entry(
@@ -94,24 +93,22 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data["alarm_zones"])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
from .utils import bridge_api_call
from .utils import DeviceType, bridge_api_call, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -28,35 +28,20 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ComelitSwitchEntity] = []
entities.extend(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[IRRIGATION].values()
)
entities.extend(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[OTHER].values()
)
async_add_entities(entities)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
known_devices: dict[str, set[int]] = {
dev_type: set() for dev_type in (IRRIGATION, OTHER)
}
def _check_device() -> None:
for dev_type in (IRRIGATION, OTHER):
current_devices = set(coordinator.data[dev_type])
new_devices = current_devices - known_devices[dev_type]
if new_devices:
known_devices[dev_type].update(new_devices)
async_add_entities(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
for dev_type in (IRRIGATION, OTHER):
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, dev_type)
)
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):

View File

@@ -4,7 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.api import (
ComelitSerialBridgeObject,
ComelitVedoAreaObject,
ComelitVedoZoneObject,
)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
@@ -19,8 +23,11 @@ from homeassistant.helpers import (
)
from .const import _LOGGER, DOMAIN
from .coordinator import ComelitBaseCoordinator
from .entity import ComelitBridgeBaseEntity
DeviceType = ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject
async def async_client_session(hass: HomeAssistant) -> ClientSession:
"""Return a new aiohttp session."""
@@ -113,3 +120,41 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
self.coordinator.config_entry.async_start_reauth(self.hass)
return cmd_wrapper
def new_device_listener(
coordinator: ComelitBaseCoordinator,
new_devices_callback: Callable[
[
list[
ComelitSerialBridgeObject
| ComelitVedoAreaObject
| ComelitVedoZoneObject
],
str,
],
None,
],
data_type: str,
) -> Callable[[], None]:
"""Subscribe to coordinator updates to check for new devices."""
known_devices: set[int] = set()
def _check_devices() -> None:
"""Check for new devices and call callback with any new monitors."""
if not coordinator.data:
return
new_devices: list[DeviceType] = []
for _id in coordinator.data[data_type]:
if _id not in known_devices:
known_devices.add(_id)
new_devices.append(coordinator.data[data_type][_id])
if new_devices:
new_devices_callback(new_devices, data_type)
# Check for devices immediately
_check_devices()
return coordinator.async_add_listener(_check_devices)

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/control4",
"iot_class": "local_polling",
"loggers": ["pyControl4"],
"requirements": ["pyControl4==1.2.0"],
"requirements": ["pyControl4==1.5.0"],
"ssdp": [
{
"st": "c4:director"

View File

@@ -514,7 +514,7 @@ class ChatLog:
"""Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None
if user_llm_hass_api is None:
if not user_llm_hass_api:
pass
elif isinstance(user_llm_hass_api, llm.API):
llm_api = await user_llm_hass_api.async_get_api_instance(llm_context)

View File

@@ -38,22 +38,30 @@ from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
FuzzyLanguageResponses,
LanguageScores,
get_fuzzy_config,
get_fuzzy_language,
get_intents,
get_language_scores,
get_languages,
)
import yaml
from homeassistant import core
from homeassistant.components.homeassistant.exposed_entities import (
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.core import Event, callback
from homeassistant.core import (
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@@ -192,7 +200,7 @@ class IntentCache:
async def async_setup_default_agent(
hass: core.HomeAssistant,
hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None:
@@ -201,15 +209,13 @@ async def async_setup_default_agent(
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@core.callback
def async_entity_state_listener(
event: core.Event[core.EventStateChangedData],
) -> None:
@callback
def async_entity_state_listener(event: Event[EventStateChangedData]) -> None:
"""Set expose flag on new entities."""
async_should_expose(hass, DOMAIN, event.data["entity_id"])
@core.callback
def async_hass_started(hass: core.HomeAssistant) -> None:
@callback
def async_hass_started(hass: HomeAssistant) -> None:
"""Set expose flag on all entities."""
for state in hass.states.async_all():
async_should_expose(hass, DOMAIN, state.entity_id)
@@ -224,9 +230,7 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(
self, hass: core.HomeAssistant, config_intents: dict[str, Any]
) -> None:
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
@@ -259,7 +263,7 @@ class DefaultAgent(ConversationEntity):
"""Return a list of supported languages."""
return get_languages()
@core.callback
@callback
def _filter_entity_registry_changes(
self, event_data: er.EventEntityRegistryUpdatedData
) -> bool:
@@ -268,12 +272,12 @@ class DefaultAgent(ConversationEntity):
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
)
@core.callback
def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
@callback
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
"""Filter state changed events."""
return not event_data["old_state"] or not event_data["new_state"]
@core.callback
@callback
def _listen_clear_slot_list(self) -> None:
"""Listen for changes that can invalidate slot list."""
assert self._unsub_clear_slot_list is None
@@ -342,6 +346,81 @@ class DefaultAgent(ConversationEntity):
return result
async def async_debug_recognize(
self, user_input: ConversationInput
) -> dict[str, Any] | None:
"""Debug recognize from user input."""
result_dict: dict[str, Any] | None = None
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": trigger_result.sentence_template or "",
}
elif intent_result := await self.async_recognize_intent(user_input):
successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(
self.hass, intent_result
)
}
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
return result_dict
async def _async_handle_message(
self,
user_input: ConversationInput,
@@ -890,7 +969,7 @@ class DefaultAgent(ConversationEntity):
) -> str:
# Get first matched or unmatched state.
# This is available in the response template as "state".
state1: core.State | None = None
state1: State | None = None
if intent_response.matched_states:
state1 = intent_response.matched_states[0]
elif intent_response.unmatched_states:
@@ -1528,6 +1607,10 @@ class DefaultAgent(ConversationEntity):
return None
return response
async def async_get_language_scores(self) -> dict[str, LanguageScores]:
"""Get support scores per language."""
return await self.hass.async_add_executor_job(get_language_scores)
def _make_error_result(
language: str,
@@ -1589,7 +1672,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
def _get_match_error_response(
hass: core.HomeAssistant,
hass: HomeAssistant,
match_error: intent.MatchFailedError,
) -> tuple[ErrorKey, dict[str, Any]]:
"""Return key and template arguments for error when target matching fails."""
@@ -1724,3 +1807,75 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No
elif isinstance(expression, ListReference):
# {list}
list_names.add(expression.slot_name)
def _get_debug_targets(
hass: HomeAssistant,
result: RecognizeResult,
) -> Iterable[tuple[State, bool]]:
"""Yield state/is_matched pairs for a hassil recognition."""
entities = result.entities
name: str | None = None
area_name: str | None = None
domains: set[str] | None = None
device_classes: set[str] | None = None
state_names: set[str] | None = None
if "name" in entities:
name = str(entities["name"].value)
if "area" in entities:
area_name = str(entities["area"].value)
if "domain" in entities:
domains = set(cv.ensure_list(entities["domain"].value))
if "device_class" in entities:
device_classes = set(cv.ensure_list(entities["device_class"].value))
if "state" in entities:
# HassGetState only
state_names = set(cv.ensure_list(entities["state"].value))
if (
(name is None)
and (area_name is None)
and (not domains)
and (not device_classes)
and (not state_names)
):
# Avoid "matching" all entities when there is no filter
return
states = intent.async_match_states(
hass,
name=name,
area_name=area_name,
domains=domains,
device_classes=device_classes,
)
for state in states:
# For queries, a target is "matched" based on its state
is_matched = (state_names is None) or (state.state in state_names)
yield state, is_matched
def _get_unmatched_slots(
result: RecognizeResult,
) -> dict[str, str | int | float]:
"""Return a dict of unmatched text/range slot entities."""
unmatched_slots: dict[str, str | int | float] = {}
for entity in result.unmatched_entities_list:
if isinstance(entity, UnmatchedTextEntity):
if entity.text == MISSING_ENTITY:
# Don't report <missing> since these are just missing context
# slots.
continue
unmatched_slots[entity.name] = entity.text
elif isinstance(entity, UnmatchedRangeEntity):
unmatched_slots[entity.name] = entity.value
return unmatched_slots

View File

@@ -2,21 +2,16 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import asdict
from typing import Any
from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from home_assistant_intents import get_language_scores
import voluptuous as vol
from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import language as language_util
from .agent_manager import (
@@ -26,11 +21,6 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
METADATA_FUZZY_MATCH,
)
from .entity import ConversationEntity
from .models import ConversationInput
@@ -206,150 +196,12 @@ async def websocket_hass_agent_debug(
language=msg.get("language", hass.config.language),
agent_id=agent.entity_id,
)
result_dict: dict[str, Any] | None = None
if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": trigger_result.sentence_template or "",
}
elif intent_result := await agent.async_recognize_intent(user_input):
successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(hass, intent_result)
}
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
result_dict = await agent.async_debug_recognize(user_input)
result_dicts.append(result_dict)
connection.send_result(msg["id"], {"results": result_dicts})
def _get_debug_targets(
hass: HomeAssistant,
result: RecognizeResult,
) -> Iterable[tuple[State, bool]]:
"""Yield state/is_matched pairs for a hassil recognition."""
entities = result.entities
name: str | None = None
area_name: str | None = None
domains: set[str] | None = None
device_classes: set[str] | None = None
state_names: set[str] | None = None
if "name" in entities:
name = str(entities["name"].value)
if "area" in entities:
area_name = str(entities["area"].value)
if "domain" in entities:
domains = set(cv.ensure_list(entities["domain"].value))
if "device_class" in entities:
device_classes = set(cv.ensure_list(entities["device_class"].value))
if "state" in entities:
# HassGetState only
state_names = set(cv.ensure_list(entities["state"].value))
if (
(name is None)
and (area_name is None)
and (not domains)
and (not device_classes)
and (not state_names)
):
# Avoid "matching" all entities when there is no filter
return
states = intent.async_match_states(
hass,
name=name,
area_name=area_name,
domains=domains,
device_classes=device_classes,
)
for state in states:
# For queries, a target is "matched" based on its state
is_matched = (state_names is None) or (state.state in state_names)
yield state, is_matched
def _get_unmatched_slots(
result: RecognizeResult,
) -> dict[str, str | int | float]:
"""Return a dict of unmatched text/range slot entities."""
unmatched_slots: dict[str, str | int | float] = {}
for entity in result.unmatched_entities_list:
if isinstance(entity, UnmatchedTextEntity):
if entity.text == MISSING_ENTITY:
# Don't report <missing> since these are just missing context
# slots.
continue
unmatched_slots[entity.name] = entity.text
elif isinstance(entity, UnmatchedRangeEntity):
unmatched_slots[entity.name] = entity.value
return unmatched_slots
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
@@ -364,10 +216,13 @@ async def websocket_hass_agent_language_scores(
msg: dict[str, Any],
) -> None:
"""Get support scores per language."""
agent = get_agent_manager(hass).default_agent
assert agent is not None
language = msg.get("language", hass.config.language)
country = msg.get("country", hass.config.country)
scores = await hass.async_add_executor_job(get_language_scores)
scores = await agent.async_get_language_scores()
matching_langs = language_util.matches(language, scores.keys(), country=country)
preferred_lang = matching_langs[0] if matching_langs else language
result = {

View File

@@ -13,7 +13,7 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
from homeassistant.const import (
SERVICE_CLOSE_COVER,
SERVICE_CLOSE_COVER_TILT,
SERVICE_OPEN_COVER,
@@ -24,19 +24,9 @@ from homeassistant.const import ( # noqa: F401
SERVICE_STOP_COVER_TILT,
SERVICE_TOGGLE,
SERVICE_TOGGLE_COVER_TILT,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -63,15 +53,6 @@ class CoverState(StrEnum):
OPENING = "opening"
# STATE_* below are deprecated as of 2024.11
# when imported from homeassistant.components.cover
# use the CoverState enum instead.
_DEPRECATED_STATE_CLOSED = DeprecatedConstantEnum(CoverState.CLOSED, "2025.11")
_DEPRECATED_STATE_CLOSING = DeprecatedConstantEnum(CoverState.CLOSING, "2025.11")
_DEPRECATED_STATE_OPEN = DeprecatedConstantEnum(CoverState.OPEN, "2025.11")
_DEPRECATED_STATE_OPENING = DeprecatedConstantEnum(CoverState.OPENING, "2025.11")
class CoverDeviceClass(StrEnum):
"""Device class for cover."""
@@ -463,11 +444,3 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return (
fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"]
)
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = ft.partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
@@ -146,6 +147,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
name=f"{name_prefix} Consumption",
source=DOMAIN,
statistic_id=consumption_statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
if meter["serviceType"] == "ELECTRIC"
else UnitOfVolume.CENTUM_CUBIC_FEET,

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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from elevenlabs import AsyncElevenLabs, Model
from elevenlabs.core import ApiError
@@ -18,9 +19,14 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MODEL
from .const import CONF_MODEL, CONF_STT_MODEL
PLATFORMS: list[Platform] = [Platform.TTS]
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.STT,
Platform.TTS,
]
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
@@ -39,6 +45,7 @@ class ElevenLabsData:
client: AsyncElevenLabs
model: Model
stt_model: str
type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData]
@@ -62,7 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -
if model is None or (not model.languages):
raise ConfigEntryError("Model could not be resolved")
entry.runtime_data = ElevenLabsData(client=client, model=model)
entry.runtime_data = ElevenLabsData(
client=client, model=model, stt_model=entry.options[CONF_STT_MODEL]
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -78,3 +87,44 @@ async def update_listener(
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ElevenLabsConfigEntry
) -> bool:
"""Migrate old config entry to new format."""
_LOGGER.debug(
"Migrating configuration from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new_options = {**config_entry.options}
if config_entry.minor_version < 2:
# Add defaults only if theyre not already present
if "stt_auto_language" not in new_options:
new_options["stt_auto_language"] = False
if "stt_model" not in new_options:
new_options["stt_model"] = "scribe_v1"
hass.config_entries.async_update_entry(
config_entry,
options=new_options,
minor_version=2,
version=1,
)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True # already up to date

View File

@@ -25,15 +25,20 @@ from .const import (
CONF_MODEL,
CONF_SIMILARITY,
CONF_STABILITY,
CONF_STT_AUTO_LANGUAGE,
CONF_STT_MODEL,
CONF_STYLE,
CONF_USE_SPEAKER_BOOST,
CONF_VOICE,
DEFAULT_MODEL,
DEFAULT_SIMILARITY,
DEFAULT_STABILITY,
DEFAULT_STT_AUTO_LANGUAGE,
DEFAULT_STT_MODEL,
DEFAULT_STYLE,
DEFAULT_TTS_MODEL,
DEFAULT_USE_SPEAKER_BOOST,
DOMAIN,
STT_MODELS,
)
USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
@@ -68,6 +73,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for ElevenLabs text-to-speech."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -88,7 +94,12 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title="ElevenLabs",
data=user_input,
options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]},
options={
CONF_MODEL: DEFAULT_TTS_MODEL,
CONF_VOICE: list(voices)[0],
CONF_STT_MODEL: DEFAULT_STT_MODEL,
CONF_STT_AUTO_LANGUAGE: False,
},
)
return self.async_show_form(
step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors
@@ -113,6 +124,9 @@ class ElevenLabsOptionsFlow(OptionsFlow):
self.models: dict[str, str] = {}
self.model: str | None = None
self.voice: str | None = None
self.stt_models: dict[str, str] = STT_MODELS
self.stt_model: str | None = None
self.auto_language: bool | None = None
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -126,6 +140,8 @@ class ElevenLabsOptionsFlow(OptionsFlow):
if user_input is not None:
self.model = user_input[CONF_MODEL]
self.voice = user_input[CONF_VOICE]
self.stt_model = user_input[CONF_STT_MODEL]
self.auto_language = user_input[CONF_STT_AUTO_LANGUAGE]
configure_voice = user_input.pop(CONF_CONFIGURE_VOICE)
if configure_voice:
return await self.async_step_voice_settings()
@@ -165,6 +181,22 @@ class ElevenLabsOptionsFlow(OptionsFlow):
]
)
),
vol.Required(
CONF_STT_MODEL,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(label=model_name, value=model_id)
for model_id, model_name in self.stt_models.items()
]
)
),
vol.Required(
CONF_STT_AUTO_LANGUAGE,
default=self.config_entry.options.get(
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
),
): bool,
vol.Required(CONF_CONFIGURE_VOICE, default=False): bool,
}
),
@@ -179,6 +211,8 @@ class ElevenLabsOptionsFlow(OptionsFlow):
if user_input is not None:
user_input[CONF_MODEL] = self.model
user_input[CONF_VOICE] = self.voice
user_input[CONF_STT_MODEL] = self.stt_model
user_input[CONF_STT_AUTO_LANGUAGE] = self.auto_language
return self.async_create_entry(
title="ElevenLabs",
data=user_input,

View File

@@ -7,12 +7,123 @@ CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability"
CONF_SIMILARITY = "similarity"
CONF_STT_AUTO_LANGUAGE = "stt_auto_language"
CONF_STT_MODEL = "stt_model"
CONF_STYLE = "style"
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
DOMAIN = "elevenlabs"
DEFAULT_MODEL = "eleven_multilingual_v2"
DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
DEFAULT_STABILITY = 0.5
DEFAULT_SIMILARITY = 0.75
DEFAULT_STT_AUTO_LANGUAGE = False
DEFAULT_STT_MODEL = "scribe_v1"
DEFAULT_STYLE = 0
DEFAULT_USE_SPEAKER_BOOST = True
STT_LANGUAGES = [
"af-ZA", # Afrikaans
"am-ET", # Amharic
"ar-SA", # Arabic
"hy-AM", # Armenian
"as-IN", # Assamese
"ast-ES", # Asturian
"az-AZ", # Azerbaijani
"be-BY", # Belarusian
"bn-IN", # Bengali
"bs-BA", # Bosnian
"bg-BG", # Bulgarian
"my-MM", # Burmese
"yue-HK", # Cantonese
"ca-ES", # Catalan
"ceb-PH", # Cebuano
"ny-MW", # Chichewa
"hr-HR", # Croatian
"cs-CZ", # Czech
"da-DK", # Danish
"nl-NL", # Dutch
"en-US", # English
"et-EE", # Estonian
"fil-PH", # Filipino
"fi-FI", # Finnish
"fr-FR", # French
"ff-SN", # Fulah
"gl-ES", # Galician
"lg-UG", # Ganda
"ka-GE", # Georgian
"de-DE", # German
"el-GR", # Greek
"gu-IN", # Gujarati
"ha-NG", # Hausa
"he-IL", # Hebrew
"hi-IN", # Hindi
"hu-HU", # Hungarian
"is-IS", # Icelandic
"ig-NG", # Igbo
"id-ID", # Indonesian
"ga-IE", # Irish
"it-IT", # Italian
"ja-JP", # Japanese
"jv-ID", # Javanese
"kea-CV", # Kabuverdianu
"kn-IN", # Kannada
"kk-KZ", # Kazakh
"km-KH", # Khmer
"ko-KR", # Korean
"ku-TR", # Kurdish
"ky-KG", # Kyrgyz
"lo-LA", # Lao
"lv-LV", # Latvian
"ln-CD", # Lingala
"lt-LT", # Lithuanian
"luo-KE", # Luo
"lb-LU", # Luxembourgish
"mk-MK", # Macedonian
"ms-MY", # Malay
"ml-IN", # Malayalam
"mt-MT", # Maltese
"zh-CN", # Mandarin Chinese
"mi-NZ", # Māori
"mr-IN", # Marathi
"mn-MN", # Mongolian
"ne-NP", # Nepali
"nso-ZA", # Northern Sotho
"no-NO", # Norwegian
"oc-FR", # Occitan
"or-IN", # Odia
"ps-AF", # Pashto
"fa-IR", # Persian
"pl-PL", # Polish
"pt-PT", # Portuguese
"pa-IN", # Punjabi
"ro-RO", # Romanian
"ru-RU", # Russian
"sr-RS", # Serbian
"sn-ZW", # Shona
"sd-PK", # Sindhi
"sk-SK", # Slovak
"sl-SI", # Slovenian
"so-SO", # Somali
"es-ES", # Spanish
"sw-KE", # Swahili
"sv-SE", # Swedish
"ta-IN", # Tamil
"tg-TJ", # Tajik
"te-IN", # Telugu
"th-TH", # Thai
"tr-TR", # Turkish
"uk-UA", # Ukrainian
"umb-AO", # Umbundu
"ur-PK", # Urdu
"uz-UZ", # Uzbek
"vi-VN", # Vietnamese
"cy-GB", # Welsh
"wo-SN", # Wolof
"xh-ZA", # Xhosa
"zu-ZA", # Zulu
]
STT_MODELS = {
"scribe_v1": "Scribe v1",
"scribe_v1_experimental": "Scribe v1 Experimental",
}

View File

@@ -21,11 +21,15 @@
"data": {
"voice": "Voice",
"model": "Model",
"stt_model": "Speech-to-Text Model",
"stt_auto_language": "Auto-detect language",
"configure_voice": "Configure advanced voice settings"
},
"data_description": {
"voice": "Voice to use for the TTS.",
"voice": "Voice to use for text-to-speech.",
"model": "ElevenLabs model to use. Please note that not all models support all languages equally well.",
"stt_model": "Speech-to-Text model to use.",
"stt_auto_language": "Automatically detect the spoken language for speech-to-text.",
"configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation."
}
},
@@ -44,5 +48,17 @@
}
}
}
},
"entity": {
"tts": {
"elevenlabs_tts": {
"name": "Text-to-Speech"
}
},
"stt": {
"elevenlabs_stt": {
"name": "Speech-to-Text"
}
}
}
}

View File

@@ -0,0 +1,207 @@
"""Support for the ElevenLabs speech-to-text service."""
from __future__ import annotations
from collections.abc import AsyncIterable
from io import BytesIO
import logging
from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
from elevenlabs.types import Model
from homeassistant.components import stt
from homeassistant.components.stt import (
AudioBitRates,
AudioChannels,
AudioCodecs,
AudioFormats,
AudioSampleRates,
SpeechMetadata,
SpeechResultState,
SpeechToTextEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
CONF_STT_AUTO_LANGUAGE,
DEFAULT_STT_AUTO_LANGUAGE,
DOMAIN,
STT_LANGUAGES,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElevenLabsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ElevenLabs stt platform via config entry."""
client = config_entry.runtime_data.client
auto_detect = config_entry.options.get(
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
)
async_add_entities(
[
ElevenLabsSTTEntity(
client,
config_entry.runtime_data.model,
config_entry.runtime_data.stt_model,
config_entry.entry_id,
auto_detect_language=auto_detect,
)
]
)
class ElevenLabsSTTEntity(SpeechToTextEntity):
"""The ElevenLabs STT API entity."""
_attr_has_entity_name = True
_attr_translation_key = "elevenlabs_stt"
def __init__(
self,
client: AsyncElevenLabs,
model: Model,
stt_model: str,
entry_id: str,
auto_detect_language: bool = False,
) -> None:
"""Init ElevenLabs TTS service."""
self._client = client
self._auto_detect_language = auto_detect_language
self._stt_model = stt_model
# Entity attributes
self._attr_unique_id = entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
manufacturer="ElevenLabs",
model=model.name,
name="ElevenLabs",
entry_type=DeviceEntryType.SERVICE,
)
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
return STT_LANGUAGES
@property
def supported_formats(self) -> list[AudioFormats]:
"""Return a list of supported formats."""
return [AudioFormats.WAV, AudioFormats.OGG]
@property
def supported_codecs(self) -> list[AudioCodecs]:
"""Return a list of supported codecs."""
return [AudioCodecs.PCM, AudioCodecs.OPUS]
@property
def supported_bit_rates(self) -> list[AudioBitRates]:
"""Return a list of supported bit rates."""
return [AudioBitRates.BITRATE_16]
@property
def supported_sample_rates(self) -> list[AudioSampleRates]:
"""Return a list of supported sample rates."""
return [AudioSampleRates.SAMPLERATE_16000]
@property
def supported_channels(self) -> list[AudioChannels]:
"""Return a list of supported channels."""
return [
AudioChannels.CHANNEL_MONO,
AudioChannels.CHANNEL_STEREO,
]
async def async_process_audio_stream(
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
) -> stt.SpeechResult:
"""Process an audio stream to STT service."""
_LOGGER.debug(
"Processing audio stream for STT: model=%s, language=%s, format=%s, codec=%s, sample_rate=%s, channels=%s, bit_rate=%s",
self._stt_model,
metadata.language,
metadata.format,
metadata.codec,
metadata.sample_rate,
metadata.channel,
metadata.bit_rate,
)
if self._auto_detect_language:
lang_code = None
else:
language = metadata.language
if language.lower() not in [lang.lower() for lang in STT_LANGUAGES]:
_LOGGER.warning("Unsupported language: %s", language)
return stt.SpeechResult(None, SpeechResultState.ERROR)
lang_code = language.split("-")[0]
raw_pcm_compatible = (
metadata.codec == AudioCodecs.PCM
and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000
and metadata.channel == AudioChannels.CHANNEL_MONO
and metadata.bit_rate == AudioBitRates.BITRATE_16
)
if raw_pcm_compatible:
file_format = "pcm_s16le_16"
elif metadata.codec == AudioCodecs.PCM:
_LOGGER.warning("PCM input does not meet expected raw format requirements")
return stt.SpeechResult(None, SpeechResultState.ERROR)
else:
file_format = "other"
audio = b""
async for chunk in stream:
audio += chunk
_LOGGER.debug("Finished reading audio stream, total size: %d bytes", len(audio))
if not audio:
_LOGGER.warning("No audio received in stream")
return stt.SpeechResult(None, SpeechResultState.ERROR)
lang_display = lang_code if lang_code else "auto-detected"
_LOGGER.debug(
"Transcribing audio (%s), format: %s, size: %d bytes",
lang_display,
file_format,
len(audio),
)
try:
response = await self._client.speech_to_text.convert(
file=BytesIO(audio),
file_format=file_format,
model_id=self._stt_model,
language_code=lang_code,
tag_audio_events=False,
num_speakers=1,
diarize=False,
)
except ApiError as exc:
_LOGGER.error("Error during processing of STT request: %s", exc)
return stt.SpeechResult(None, SpeechResultState.ERROR)
text = response.text or ""
detected_lang_code = response.language_code or "?"
detected_lang_prob = response.language_probability or "?"
_LOGGER.debug(
"Transcribed text is in language %s (probability %s): %s",
detected_lang_code,
detected_lang_prob,
text,
)
return stt.SpeechResult(text, SpeechResultState.SUCCESS)

View File

@@ -71,7 +71,6 @@ async def async_setup_entry(
voices,
default_voice_id,
config_entry.entry_id,
config_entry.title,
voice_settings,
)
]
@@ -83,6 +82,8 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
_attr_entity_category = EntityCategory.CONFIG
_attr_has_entity_name = True
_attr_translation_key = "elevenlabs_tts"
def __init__(
self,
@@ -91,7 +92,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
voices: list[ElevenLabsVoice],
default_voice_id: str,
entry_id: str,
title: str,
voice_settings: VoiceSettings,
) -> None:
"""Init ElevenLabs TTS service."""
@@ -112,11 +112,11 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
# Entity attributes
self._attr_unique_id = entry_id
self._attr_name = title
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
manufacturer="ElevenLabs",
model=model.name,
name="ElevenLabs",
entry_type=DeviceEntryType.SERVICE,
)
self._attr_supported_languages = [

View File

@@ -20,6 +20,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.components.recorder.util import get_instance
from homeassistant.const import UnitOfEnergy
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN, LOGGER
@@ -153,6 +154,7 @@ class ElviaImporter:
name=f"{self.metering_point_id} Consumption",
source=DOMAIN,
statistic_id=statistic_id,
unit_class=EnergyConverter.UNIT_CLASS,
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
),
statistics=statistics,

View File

@@ -47,11 +47,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
try:
await radar_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada radar")
# Skip initial refresh for radar since the camera entity is disabled by default.
# The coordinator will fetch data when the entity is enabled.
aqhi_data = ECAirQuality(coordinates=(lat, lon))
aqhi_coordinator = ECDataUpdateCoordinator(
@@ -63,7 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada AQHI")
if errors == 3:
# Require at least one coordinator to succeed (weather or AQHI)
# Radar is optional since the camera entity is disabled by default
if errors >= 2:
raise ConfigEntryNotReady
config_entry.runtime_data = ECRuntimeData(

View File

@@ -59,6 +59,14 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
self.content_type = "image/gif"
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# Trigger coordinator refresh when entity is enabled
# since radar coordinator skips initial refresh during setup
if not self.coordinator.last_update_success:
await self.coordinator.async_request_refresh()
def camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:

View File

@@ -6,11 +6,18 @@ import xml.etree.ElementTree as ET
import aiohttp
from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
@@ -25,14 +32,16 @@ async def validate_input(data):
lang = data.get(CONF_LANGUAGE).lower()
if station:
# When station is provided, use it and get the coordinates from ECWeather
weather_data = ECWeather(station_id=station, language=lang)
else:
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
await weather_data.update()
if lat is None or lon is None:
await weather_data.update()
# Always use the station's coordinates, not the user-provided ones
lat = weather_data.lat
lon = weather_data.lon
else:
# When no station is provided, use coordinates to find nearest station
weather_data = ECWeather(coordinates=(lat, lon), language=lang)
await weather_data.update()
return {
CONF_TITLE: weather_data.metadata.location,
@@ -46,6 +55,13 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Environment Canada weather."""
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
self._station_codes = await get_ec_sites_list()
return self._station_codes
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -80,9 +96,21 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info[CONF_TITLE], data=user_input)
station_codes = await self._get_station_codes()
data_schema = vol.Schema(
{
vol.Optional(CONF_STATION): str,
vol.Optional(CONF_STATION): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=station["value"], label=station["label"]
)
for station in station_codes
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.11.2"]
"requirements": ["env-canada==0.12.1"]
}

View File

@@ -3,11 +3,11 @@
"step": {
"user": {
"title": "Environment Canada: weather location and language",
"description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.",
"description": "Select a weather station from the dropdown, or specify coordinates to use the closest station. The default coordinates are from your Home Assistant installation. Weather information can be retrieved in English or French.",
"data": {
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"station": "Weather station ID",
"station": "Weather station",
"language": "Weather information language"
}
}
@@ -16,7 +16,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"bad_station_id": "Station ID is invalid, missing, or not found in the station ID database",
"bad_station_id": "Station code is invalid, missing, or not found in the station code database",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"error_response": "Response from Environment Canada in error",
"too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds",

View File

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

View File

@@ -29,7 +29,12 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.const import (
ATTR_MODE,
ATTR_TEMPERATURE,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -243,7 +248,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature."""
temperature = kwargs["temperature"]
temperature = kwargs[ATTR_TEMPERATURE]
if (until := kwargs.get("until")) is None:
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:

View File

@@ -13,27 +13,14 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_TEMPERATURE,
PRECISION_HALVES,
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_STATE_BATTERY_LOW,
ATTR_STATE_HOLIDAY_MODE,
ATTR_STATE_SUMMER_MODE,
ATTR_STATE_WINDOW_OPEN,
DOMAIN,
LOGGER,
)
from .const import DOMAIN, LOGGER
from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator
from .entity import FritzBoxDeviceEntity
from .model import ClimateExtraAttributes
from .sensor import value_scheduled_preset
HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF]
@@ -202,26 +189,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
self.check_active_or_lock_mode()
await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode])
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:
"""Return the device specific state attributes."""
# deprecated with #143394, can be removed in 2025.11
attrs: ClimateExtraAttributes = {
ATTR_STATE_BATTERY_LOW: self.data.battery_low,
}
# the following attributes are available since fritzos 7
if self.data.battery_level is not None:
attrs[ATTR_BATTERY_LEVEL] = self.data.battery_level
if self.data.holiday_active is not None:
attrs[ATTR_STATE_HOLIDAY_MODE] = self.data.holiday_active
if self.data.summer_active is not None:
attrs[ATTR_STATE_SUMMER_MODE] = self.data.summer_active
if self.data.window_open is not None:
attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open
return attrs
def check_active_or_lock_mode(self) -> None:
"""Check if in summer/vacation mode or lock enabled."""
if self.data.holiday_active or self.data.summer_active:

View File

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

View File

@@ -167,6 +167,6 @@ class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.unsubscribe)
def unsubscribe(self, *args) -> None:
def unsubscribe(self, *args: Any) -> None:
"""Unsubscribe to repository events."""
self._client.repos.events.unsubscribe(subscription_id=self._subscription_id)

View File

@@ -182,10 +182,10 @@ FAN_SPEED_MAX_SPEED_COUNT = 5
COVER_VALVE_STATES = {
cover.DOMAIN: {
"closed": cover.STATE_CLOSED,
"closing": cover.STATE_CLOSING,
"open": cover.STATE_OPEN,
"opening": cover.STATE_OPENING,
"closed": cover.CoverState.CLOSED.value,
"closing": cover.CoverState.CLOSING.value,
"open": cover.CoverState.OPEN.value,
"opening": cover.CoverState.OPENING.value,
},
valve.DOMAIN: {
"closed": valve.STATE_CLOSED,

View File

@@ -8,7 +8,12 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers import config_entry_oauth2_flow
@@ -40,6 +45,12 @@ class OAuth2FlowHandler(
"prompt": "consent",
}
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -60,6 +71,10 @@ class OAuth2FlowHandler(
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=data
)
return self.async_create_entry(
title=DEFAULT_NAME,

View File

@@ -30,7 +30,8 @@
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["pyheos"],
"quality_scale": "platinum",
"requirements": ["pyheos==1.0.5"],
"requirements": ["pyheos==1.0.6"],
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"

View File

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

View File

@@ -99,6 +99,20 @@ CLEANING_MODE_OPTIONS = {
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.IntelligentMode",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumOnly",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopOnly",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.VacuumAndMop",
"ConsumerProducts.CleaningRobot.EnumType.CleaningMode.MopAfterVacuum",
)
}
SUCTION_POWER_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Silent",
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Standard",
"ConsumerProducts.CleaningRobot.EnumType.SuctionPower.Max",
)
}
@@ -309,6 +323,10 @@ PROGRAM_ENUM_OPTIONS = {
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
CLEANING_MODE_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER,
SUCTION_POWER_OPTIONS,
),
(OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS),
(
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,

View File

@@ -30,6 +30,7 @@ from .const import (
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP,
SPIN_SPEED_OPTIONS,
SUCTION_POWER_OPTIONS,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP,
VARIO_PERFECT_OPTIONS,
@@ -168,6 +169,16 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in CLEANING_MODE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_SUCTION_POWER,
translation_key="suction_power",
options=list(SUCTION_POWER_OPTIONS),
translation_key_values=SUCTION_POWER_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in SUCTION_POWER_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
translation_key="bean_amount",

View File

@@ -202,6 +202,22 @@ set_program_and_options:
- consumer_products_cleaning_robot_enum_type_cleaning_modes_silent
- consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
- consumer_products_cleaning_robot_enum_type_cleaning_modes_power
- consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode
- consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only
- consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only
- consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop
- consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum
consumer_products_cleaning_robot_option_suction_power:
example: consumer_products_cleaning_robot_enum_type_suction_power_standard
required: false
selector:
select:
mode: dropdown
translation_key: suction_power
options:
- consumer_products_cleaning_robot_enum_type_suction_power_silent
- consumer_products_cleaning_robot_enum_type_suction_power_standard
- consumer_products_cleaning_robot_enum_type_suction_power_max
coffee_maker_options:
collapsed: true
fields:

View File

@@ -324,7 +324,19 @@
"options": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power"
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "Intelligent mode",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "Vacuum only",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "Mop only",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "Vacuum and mop",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "Mop after vacuum"
}
},
"suction_power": {
"options": {
"consumer_products_cleaning_robot_enum_type_suction_power_silent": "Silent",
"consumer_products_cleaning_robot_enum_type_suction_power_standard": "Standard",
"consumer_products_cleaning_robot_enum_type_suction_power_max": "Max"
}
},
"bean_amount": {
@@ -519,6 +531,10 @@
"name": "Cleaning mode",
"description": "Defines the favored cleaning mode."
},
"consumer_products_cleaning_robot_option_suction_power": {
"name": "Suction power",
"description": "Defines the suction power."
},
"consumer_products_coffee_maker_option_bean_amount": {
"name": "Bean amount",
"description": "Describes the amount of coffee beans used in a coffee machine program."
@@ -1196,7 +1212,20 @@
"state": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_intelligent_mode%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_only%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_only%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_vacuum_and_mop%]",
"consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_mode_mop_after_vacuum%]"
}
},
"suction_power": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_suction_power::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_suction_power_silent": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_silent%]",
"consumer_products_cleaning_robot_enum_type_suction_power_standard": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_standard%]",
"consumer_products_cleaning_robot_enum_type_suction_power_max": "[%key:component::home_connect::selector::suction_power::options::consumer_products_cleaning_robot_enum_type_suction_power_max%]"
}
},
"bean_amount": {

View File

@@ -13,6 +13,12 @@
"pid": "4001",
"description": "*zbt-2*",
"known_devices": ["ZBT-2"]
},
{
"vid": "303A",
"pid": "831A",
"description": "*zbt-2*",
"known_devices": ["ZBT-2"]
}
]
}

View File

@@ -1,15 +1,20 @@
"""Home Assistant Hardware integration helpers."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import logging
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from . import DATA_COMPONENT
from .util import FirmwareInfo
if TYPE_CHECKING:
from .util import FirmwareInfo
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +56,7 @@ class HardwareInfoDispatcher:
self._notification_callbacks: defaultdict[
str, set[Callable[[FirmwareInfo], None]]
] = defaultdict(set)
self._active_firmware_updates: dict[str, str] = {}
def register_firmware_info_provider(
self, domain: str, platform: HardwareFirmwareInfoModule
@@ -118,6 +124,36 @@ class HardwareInfoDispatcher:
if fw_info is not None:
yield fw_info
def register_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
if device in self._active_firmware_updates:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update already in progress for {device} by {current_domain}"
)
self._active_firmware_updates[device] = source_domain
def unregister_firmware_update_in_progress(
self, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
if device not in self._active_firmware_updates:
raise ValueError(f"No firmware update in progress for {device}")
if self._active_firmware_updates[device] != source_domain:
current_domain = self._active_firmware_updates[device]
raise ValueError(
f"Firmware update for {device} is owned by {current_domain}, not {source_domain}"
)
del self._active_firmware_updates[device]
def is_firmware_update_in_progress(self, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return device in self._active_firmware_updates
@hass_callback
def async_register_firmware_info_provider(
@@ -141,3 +177,42 @@ def async_notify_firmware_info(
) -> Awaitable[None]:
"""Notify the dispatcher of new firmware information."""
return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info)
@hass_callback
def async_register_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Register that a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].register_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_unregister_firmware_update_in_progress(
hass: HomeAssistant, device: str, source_domain: str
) -> None:
"""Unregister a firmware update for a device."""
return hass.data[DATA_COMPONENT].unregister_firmware_update_in_progress(
device, source_domain
)
@hass_callback
def async_is_firmware_update_in_progress(hass: HomeAssistant, device: str) -> bool:
"""Check if a firmware update is in progress for a device."""
return hass.data[DATA_COMPONENT].is_firmware_update_in_progress(device)
@asynccontextmanager
async def async_firmware_update_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
"""Register a device as having its firmware being actively updated."""
async_register_firmware_update_in_progress(hass, device, source_domain)
try:
yield
finally:
async_unregister_firmware_update_in_progress(hass, device, source_domain)

View File

@@ -67,7 +67,7 @@
}
},
"abort": {
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.",
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",

View File

@@ -275,6 +275,7 @@ class BaseFirmwareUpdateEntity(
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)
finally:
self._attr_in_progress = False

View File

@@ -26,6 +26,7 @@ from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import (
DOMAIN,
OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME,
OTBR_ADDON_SLUG,
@@ -33,6 +34,7 @@ from .const import (
ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG,
)
from .helpers import async_firmware_update_context
from .silabs_multiprotocol_addon import (
WaitingAddonManager,
get_multiprotocol_addon_manager,
@@ -359,45 +361,50 @@ async def async_flash_silabs_firmware(
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
return probed_firmware_info
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=progress_callback
)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info

View File

@@ -456,7 +456,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
return self._available
@ha_callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event."""
if state := self.hass.states.get(self.entity_id):
@@ -725,7 +725,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
self._entry_title = entry_title
self.iid_storage = iid_storage
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def pair(
self, client_username_bytes: bytes, client_public: str, client_permissions: int
) -> bool:
@@ -735,7 +735,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
async_dismiss_setup_message(self.hass, self.entry_id)
return cast(bool, success)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def unpair(self, client_uuid: UUID) -> None:
"""Override super function to show setup message if unpaired."""
super().unpair(client_uuid)

View File

@@ -71,7 +71,7 @@ class HomeDoorbellAccessory(HomeAccessory):
self.async_update_doorbell_state(None, state)
@ha_callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle doorbell event."""
if self._char_doorbell_detected:

View File

@@ -219,7 +219,7 @@ class AirPurifier(Fan):
return preset_mode.lower() != "auto"
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -229,7 +229,7 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
)
self._async_update_motion_state(None, state)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
@callback
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -127,7 +127,7 @@ class GarageDoorOpener(HomeAccessory):
self.async_update_state(state)
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -178,7 +178,7 @@ class HumidifierDehumidifier(HomeAccessory):
self._async_update_current_humidity(humidity_state)
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -108,7 +108,7 @@ class DeviceTriggerAccessory(HomeAccessory):
_LOGGER.log,
)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
@callback
def run(self) -> None:
"""Run the accessory."""

View File

@@ -158,7 +158,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_kwh,
value_fn=lambda data: data.measurement.energy_import_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t1_kwh",
@@ -172,7 +172,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
data.measurement.energy_import_t1_kwh is not None
and data.measurement.energy_export_t2_kwh is not None
),
value_fn=lambda data: data.measurement.energy_import_t1_kwh,
value_fn=lambda data: data.measurement.energy_import_t1_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t2_kwh",
@@ -182,7 +182,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t2_kwh,
value_fn=lambda data: data.measurement.energy_import_t2_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t3_kwh",
@@ -192,7 +192,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t3_kwh,
value_fn=lambda data: data.measurement.energy_import_t3_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_import_t4_kwh",
@@ -202,7 +202,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None,
value_fn=lambda data: data.measurement.energy_import_t4_kwh,
value_fn=lambda data: data.measurement.energy_import_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_kwh",
@@ -212,7 +212,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_kwh,
value_fn=lambda data: data.measurement.energy_export_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t1_kwh",
@@ -227,7 +227,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
and data.measurement.energy_export_t2_kwh is not None
),
enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t1_kwh,
value_fn=lambda data: data.measurement.energy_export_t1_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t2_kwh",
@@ -238,7 +238,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t2_kwh,
value_fn=lambda data: data.measurement.energy_export_t2_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t3_kwh",
@@ -249,7 +249,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t3_kwh,
value_fn=lambda data: data.measurement.energy_export_t3_kwh or None,
),
HomeWizardSensorEntityDescription(
key="total_power_export_t4_kwh",
@@ -260,7 +260,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None,
enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t4_kwh,
value_fn=lambda data: data.measurement.energy_export_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="active_power_w",

View File

@@ -89,12 +89,12 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity."""
super().__init__(coordinator)
self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)},
manufacturer="Husqvarna",
model=self.mower_attributes.system.model.removeprefix(
"HUSQVARNA "
).removeprefix("Husqvarna "),
manufacturer=parts[0],
model=parts[1],
model_id=parts[2],
name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden",

View File

@@ -41,16 +41,12 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
self._expected_connected = False
self._height: int | None = None
@callback
def async_update_data() -> None:
self.async_set_updated_data(self._height)
self._debouncer = Debouncer(
hass=self.hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=async_update_data,
function=callback(lambda: self.async_set_updated_data(self._height)),
)
async def async_connect(self) -> bool:

View File

@@ -5,6 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["imgw_pib==1.5.6"]
}

View File

@@ -50,17 +50,17 @@ rules:
discovery:
status: exempt
comment: The integration is a cloud service and thus does not support discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: This is a service, which doesn't integrate with any devices.
docs-supported-functions: todo
docs-supported-functions: done
docs-troubleshooting:
status: exempt
comment: No known issues that could be resolved by the user.
docs-use-cases: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This integration has a fixed single service.

View File

@@ -1,12 +1,12 @@
{
"domain": "iometer",
"name": "IOmeter",
"codeowners": ["@MaestroOnICe"],
"codeowners": ["@jukrebs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iometer",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==0.1.0"],
"requirements": ["iometer==0.2.0"],
"zeroconf": ["_iometer._tcp.local."]
}

View File

@@ -34,6 +34,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.unit_conversion import EnergyConverter, VolumeConverter
from .const import DOMAIN
from .coordinator import IstaConfigEntry, IstaCoordinator
@@ -49,6 +50,7 @@ class IstaSensorEntityDescription(SensorEntityDescription):
"""Ista EcoTrend Sensor Description."""
consumption_type: IstaConsumptionType
unit_class: str | None = None
value_type: IstaValueType | None = None
@@ -84,6 +86,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
suggested_display_precision=1,
consumption_type=IstaConsumptionType.HEATING,
value_type=IstaValueType.ENERGY,
unit_class=EnergyConverter.UNIT_CLASS,
),
IstaSensorEntityDescription(
key=IstaSensorEntity.HEATING_COST,
@@ -104,6 +107,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
suggested_display_precision=1,
consumption_type=IstaConsumptionType.HOT_WATER,
unit_class=VolumeConverter.UNIT_CLASS,
),
IstaSensorEntityDescription(
key=IstaSensorEntity.HOT_WATER_ENERGY,
@@ -114,6 +118,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
suggested_display_precision=1,
consumption_type=IstaConsumptionType.HOT_WATER,
value_type=IstaValueType.ENERGY,
unit_class=EnergyConverter.UNIT_CLASS,
),
IstaSensorEntityDescription(
key=IstaSensorEntity.HOT_WATER_COST,
@@ -135,6 +140,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
consumption_type=IstaConsumptionType.WATER,
unit_class=VolumeConverter.UNIT_CLASS,
),
IstaSensorEntityDescription(
key=IstaSensorEntity.WATER_COST,
@@ -276,6 +282,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity):
"name": f"{self.device_entry.name} {self.name}",
"source": DOMAIN,
"statistic_id": statistic_id,
"unit_class": self.entity_description.unit_class,
"unit_of_measurement": self.entity_description.native_unit_of_measurement,
}
if statistics:

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kegtron",
"iot_class": "local_push",
"requirements": ["kegtron-ble==0.4.0"]
"requirements": ["kegtron-ble==1.0.2"]
}

View File

@@ -36,6 +36,11 @@ from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
EnergyConverter,
TemperatureConverter,
VolumeConverter,
)
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
@@ -254,6 +259,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": DOMAIN,
"name": "Outdoor temperature",
"statistic_id": f"{DOMAIN}:temperature_outdoor",
"unit_class": TemperatureConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfTemperature.CELSIUS,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
@@ -267,6 +273,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": DOMAIN,
"name": "Energy consumption 1",
"statistic_id": f"{DOMAIN}:energy_consumption_kwh",
"unit_class": EnergyConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
@@ -279,6 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": DOMAIN,
"name": "Energy consumption 2",
"statistic_id": f"{DOMAIN}:energy_consumption_mwh",
"unit_class": EnergyConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
@@ -293,6 +301,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": DOMAIN,
"name": "Gas consumption 1",
"statistic_id": f"{DOMAIN}:gas_consumption_m3",
"unit_class": VolumeConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
@@ -307,6 +316,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": DOMAIN,
"name": "Gas consumption 2",
"statistic_id": f"{DOMAIN}:gas_consumption_ft3",
"unit_class": VolumeConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfVolume.CUBIC_FEET,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
@@ -319,6 +329,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_1",
"unit_class": VolumeConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
@@ -331,6 +342,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_2",
"unit_class": None,
"unit_of_measurement": "cats",
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
@@ -343,6 +355,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_3",
"unit_class": VolumeConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
@@ -355,6 +368,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_4",
"unit_class": VolumeConverter.UNIT_CLASS,
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
@@ -375,6 +389,7 @@ async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None:
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_5",
"unit_class": None,
"unit_of_measurement": DEGREE,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,

View File

@@ -11,9 +11,9 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "silver",
"requirements": [
"xknx==3.9.0",
"xknx==3.9.1",
"xknxproject==3.8.2",
"knx-frontend==2025.8.24.205840"
"knx-frontend==2025.10.9.185845"
],
"single_config_entry": true
}

View File

@@ -136,7 +136,7 @@ def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[SensorSchema.CONF_SYNC_STATE],
always_callback=config[SensorSchema.CONF_ALWAYS_CALLBACK],
always_callback=True,
value_type=config[CONF_TYPE],
)
@@ -159,7 +159,7 @@ class KNXSensor(KnxYamlEntity, SensorEntity):
SensorDeviceClass, self._device.ha_device_class()
)
self._attr_force_update = self._device.always_callback
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.1"]
"requirements": ["pylamarzocco==2.1.2"]
}

View File

@@ -1 +1,36 @@
"""The london_underground component."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN as DOMAIN
from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry, TubeData
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: LondonUndergroundConfigEntry
) -> bool:
"""Set up London Underground from a config entry."""
session = async_get_clientsession(hass)
data = TubeData(session)
coordinator = LondonTubeCoordinator(hass, data, config_entry=entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Forward the setup to the sensor platform
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: LondonUndergroundConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,152 @@
"""Config flow for London Underground integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from london_tube_status import TubeData
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_LINE, DEFAULT_LINES, DOMAIN, TUBE_LINES
_LOGGER = logging.getLogger(__name__)
class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for London Underground."""
VERSION = 1
MINOR_VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
_: ConfigEntry,
) -> LondonUndergroundOptionsFlow:
"""Get the options flow for this handler."""
return LondonUndergroundOptionsFlow()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
data = TubeData(session)
try:
async with asyncio.timeout(10):
await data.update()
except TimeoutError:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected error")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="London Underground",
data={},
options={CONF_LINE: user_input.get(CONF_LINE, DEFAULT_LINES)},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LINE,
default=DEFAULT_LINES,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=TUBE_LINES,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult:
"""Handle import from configuration.yaml."""
session = async_get_clientsession(self.hass)
data = TubeData(session)
try:
async with asyncio.timeout(10):
await data.update()
except Exception:
_LOGGER.exception(
"Unexpected error trying to connect before importing config, aborting import "
)
return self.async_abort(reason="cannot_connect")
_LOGGER.warning(
"Importing London Underground config from configuration.yaml: %s",
import_data,
)
# Extract lines from the sensor platform config
lines = import_data.get(CONF_LINE, DEFAULT_LINES)
if "London Overground" in lines:
_LOGGER.warning(
"London Overground was removed from the configuration as the line has been divided and renamed"
)
lines.remove("London Overground")
return self.async_create_entry(
title="London Underground",
data={},
options={CONF_LINE: import_data.get(CONF_LINE, DEFAULT_LINES)},
)
class LondonUndergroundOptionsFlow(OptionsFlowWithReload):
"""Handle options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug(
"Updating london underground with options flow user_input: %s",
user_input,
)
return self.async_create_entry(
title="",
data={CONF_LINE: user_input[CONF_LINE]},
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LINE,
default=self.config_entry.options.get(
CONF_LINE,
self.config_entry.data.get(CONF_LINE, DEFAULT_LINES),
),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=TUBE_LINES,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
),
)

View File

@@ -6,7 +6,6 @@ DOMAIN = "london_underground"
CONF_LINE = "line"
SCAN_INTERVAL = timedelta(seconds=30)
TUBE_LINES = [
@@ -18,7 +17,7 @@ TUBE_LINES = [
"Elizabeth line",
"Hammersmith & City",
"Jubilee",
"London Overground",
"London Overground", # no longer supported
"Metropolitan",
"Northern",
"Piccadilly",
@@ -31,3 +30,20 @@ TUBE_LINES = [
"Weaver",
"Windrush",
]
# Default lines to monitor if none selected
DEFAULT_LINES = [
"Bakerloo",
"Central",
"Circle",
"District",
"DLR",
"Elizabeth line",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
]

View File

@@ -8,6 +8,7 @@ from typing import cast
from london_tube_status import TubeData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -15,16 +16,23 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type LondonUndergroundConfigEntry = ConfigEntry[LondonTubeCoordinator]
class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]):
"""London Underground sensor coordinator."""
def __init__(self, hass: HomeAssistant, data: TubeData) -> None:
def __init__(
self,
hass: HomeAssistant,
data: TubeData,
config_entry: LondonUndergroundConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=None,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)

View File

@@ -2,9 +2,12 @@
"domain": "london_underground",
"name": "London Underground",
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/london_underground",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
"quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"]
"requirements": ["london-tube-status==0.5"],
"single_config_entry": true
}

View File

@@ -5,23 +5,26 @@ from __future__ import annotations
import logging
from typing import Any
from london_tube_status import TubeData
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_LINE, TUBE_LINES
from .coordinator import LondonTubeCoordinator
from .const import CONF_LINE, DOMAIN, TUBE_LINES
from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -38,18 +41,54 @@ async def async_setup_platform(
) -> None:
"""Set up the Tube sensor."""
session = async_get_clientsession(hass)
# If configuration.yaml config exists, trigger the import flow.
# If the config entry already exists, this will not be triggered as only one config is allowed.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "London Underground",
},
)
return
data = TubeData(session)
coordinator = LondonTubeCoordinator(hass, data)
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "London Underground",
},
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise PlatformNotReady
async def async_setup_entry(
hass: HomeAssistant,
entry: LondonUndergroundConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the London Underground sensor from config entry."""
async_add_entities(
LondonTubeSensor(coordinator, line) for line in config[CONF_LINE]
LondonTubeSensor(entry.runtime_data, line) for line in entry.options[CONF_LINE]
)
@@ -58,11 +97,21 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity):
_attr_attribution = "Powered by TfL Open Data"
_attr_icon = "mdi:subway"
_attr_has_entity_name = True # Use modern entity naming
def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None:
"""Initialize the London Underground sensor."""
super().__init__(coordinator)
self._name = name
# Add unique_id for proper entity registry
self._attr_unique_id = f"tube_{name.lower().replace(' ', '_')}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, DOMAIN)},
name="London Underground",
manufacturer="Transport for London",
model="Tube Status",
entry_type=DeviceEntryType.SERVICE,
)
@property
def name(self) -> str:

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