Compare commits

..

194 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
45d289565e div temporary work
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-12 06:52:20 +01:00
Daniel Hjelseth Høyer
d7e0f4e5c3 fix test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-12 06:34:31 +01:00
Joost Lekkerkerker
0fea830e04 Merge branch 'dev' into homevolt 2026-02-11 23:30:05 +01:00
Joost Lekkerkerker
44521606ec Update homeassistant/components/homevolt/sensor.py 2026-02-11 23:29:36 +01:00
Joost Lekkerkerker
47a501cfd8 Update homeassistant/components/homevolt/strings.json 2026-02-11 23:29:27 +01:00
cdnninja
07e8b780a2 Add DHCP Discovery to vesync (#162259)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-11 23:27:35 +01:00
Denis Shulyaka
e060395786 Anthropic Structured Outputs support (#162515) 2026-02-11 23:25:46 +01:00
Denis Shulyaka
661b14dec5 Deprecate OpenAI actions (#162211) 2026-02-11 23:17:15 +01:00
Christian Lackas
b8e63b7ef6 Use direct DHW status for ViCare water heater state (#162591) 2026-02-11 23:07:56 +01:00
Abílio Costa
fd78e35a86 Align number unit converters with sensor (#162662) 2026-02-11 23:07:04 +01:00
Mick Vleeshouwer
db55dfe3c7 Improve device information in Overkiz (#162419) 2026-02-11 22:39:34 +01:00
Joost Lekkerkerker
bda3121f98 Add snapshot tests to waterfurnace sensors (#162594) 2026-02-11 22:28:41 +01:00
dontinelli
fd4981f3e2 Split up coordinators in solarlog (#161169)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 22:23:32 +01:00
Manu
ae1bedd94a Add uptime ratio and avg. response time sensors to Uptime Kuma (#162785) 2026-02-11 22:09:21 +01:00
hanwg
90b67f90fa Handle config entry not loaded for Telegram bot (#161951)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 22:05:21 +01:00
cdnninja
9c821fb5f5 Mark log unavailable as complete for vesync (#162464) 2026-02-11 22:03:12 +01:00
Graham Crockford
1f9691ace1 Add charge state to Victron BLE (#162593)
Co-authored-by: Graham Crockford <badgerwithagun@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-11 21:59:56 +01:00
Paulus Schoutsen
5331cd99c6 Google Gen AI: Increase max iterations for AI Task (#162600)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 21:55:37 +01:00
Denis Shulyaka
1c3f24c78f Add TTS support for OpenAI (#162468)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 21:37:49 +01:00
Kamil Breguła
e179e74df3 Support dual cook oven in Smartthing (#156561)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:27:40 +01:00
wollew
98602bd311 Bump pyvlx to 0.2.29 (#162829) 2026-02-11 21:10:44 +01:00
Jeef
5f01124c74 Bump typedmonarchmoney to 0.7.0 (#162686) 2026-02-11 19:44:08 +00:00
Brett Adams
4b5368be8e Complete config-entry-unloading quality check in Teslemetry (#161956)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 20:31:30 +01:00
Brett Adams
6379014f13 Use chained comparison in Teslemetry update platform (#161950)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 20:07:04 +01:00
Joost Lekkerkerker
aa640020be Bump pySmartThings to 3.5.2 (#162809)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-11 20:02:28 +01:00
Simone Chemelli
92f4e600d1 Fix alarm refresh warning for Comelit SimpleHome (#162710) 2026-02-11 19:36:57 +01:00
Andreas Jakl
25a6b6fa65 Add switch platform to nrgkick integration for enabling or pausing car charging (#162563)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 19:36:36 +01:00
Manu
3cbe1295f9 Bump pythonkuma to 0.4.1 (#162773) 2026-02-11 19:35:12 +01:00
Erwin Douna
72581fb2b1 Add endpoint system df information (#160134) 2026-02-11 19:28:49 +01:00
Erwin Douna
97c89590e0 Portainer fix multiple environments & containers (#153674) 2026-02-11 19:21:36 +01:00
epenet
b6ba86f3c1 Use service helper to extract onedrive config entry (#162803) 2026-02-11 10:16:55 -08:00
epenet
cedc291872 Use service helper to extract tado config entry (#162812) 2026-02-11 10:15:38 -08:00
epenet
1d30486f82 Use service helper to extract velbus config entry (#162813) 2026-02-11 10:15:19 -08:00
Christopher Fenner
9f1b4c9035 Improve EnOcean config flow (#162751) 2026-02-11 19:14:45 +01:00
Christian Lackas
80ebb34ad1 Add smoke detector extended properties to homematicip_cloud (#161629)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 18:56:39 +01:00
hanwg
e0e11fd99d Fix bug in edit_message_media action for Telegram bot (#162762) 2026-02-11 17:55:14 +01:00
Artur Pragacz
578a933f30 Move entity name helper to module-level function (#162766) 2026-02-11 17:54:53 +01:00
Christian Lackas
57493a1f69 Add ELV-SH-SB8 Status Board switch support to homematicip_cloud (#161668) 2026-02-11 17:43:51 +01:00
Simone Chemelli
3a4100fa94 Fix image platform state for Vodafone Station (#162747)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-11 17:39:51 +01:00
theobld-ww
0c1af1d613 Add switch entities to Watts Vision + (#162699)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-11 17:39:17 +01:00
starkillerOG
4e46431798 Add additional Reolink PTZ buttons (#162793) 2026-02-11 17:29:24 +01:00
starkillerOG
bec66f49a2 Add Reolink PTZ patrol status (#162796) 2026-02-11 17:29:05 +01:00
epenet
4019768fa1 Use service helper to extract easyenergy config entry (#162791) 2026-02-11 17:24:57 +01:00
epenet
25d902fd3e Use service helper to extract google_photos config entry (#162792) 2026-02-11 17:24:44 +01:00
epenet
30f006538d Use service helper to extract google_sheets config entry (#162794) 2026-02-11 17:24:26 +01:00
epenet
15b1fee42d Use service helper to extract mastodon config entry (#162798) 2026-02-11 17:24:05 +01:00
epenet
d69b816459 Use service helper to extract mealie config entry (#162800) 2026-02-11 17:19:03 +01:00
epenet
bf79721e97 Use service helper to extract ohme config entry (#162801) 2026-02-11 17:18:25 +01:00
torben-iometer
66a0b44284 Fix missing values in battery_level in iometer (#162781) 2026-02-11 17:17:55 +01:00
Andy
8693294ea6 Add support for Nanoleaf Essentials / Replace aionanoleaf through aionanoleaf2 (#157295)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-11 17:06:40 +01:00
epenet
14ac7927f1 Use service helper to extract seventeentrack config entry (#162807) 2026-02-11 16:08:40 +01:00
Willem-Jan van Rootselaar
b4674473d7 Fix BSBLAN water heater mapping and add on/off (#160256) 2026-02-11 16:06:26 +01:00
epenet
f01ece1d3d Move TadoConfigEntry declaration (#162811) 2026-02-11 16:01:31 +01:00
epenet
08160a41a6 Use service helper to extract swiss public transport config entry (#162810) 2026-02-11 15:59:50 +01:00
epenet
e617698770 Use service helper to extract stookwijzer config entry (#162808) 2026-02-11 15:59:36 +01:00
epenet
ee31bdf18b Use service helper to extract radarr config entry (#162805) 2026-02-11 15:41:02 +01:00
epenet
305b911c0d Use service helper to extract overseerr config entry (#162804) 2026-02-11 15:36:34 +01:00
epenet
842abf78d2 Use service helper to extract risco config entry (#162806) 2026-02-11 15:35:27 +01:00
Willem-Jan van Rootselaar
134e8d1c1b Bump python-bsblan to version 4.2.0 (#162786) 2026-02-11 15:31:06 +01:00
epenet
733e90f747 Use service helper to extract immich config entry (#162797) 2026-02-11 15:02:32 +01:00
Artur Pragacz
6c92f7a864 Add integration type to mobile_app (#157719) 2026-02-11 14:48:10 +01:00
epenet
f69b5b6e8f Use service helper to extract amberelectric config entry (#162788) 2026-02-11 13:49:29 +01:00
Willem-Jan van Rootselaar
59e53ee7b7 Add HVAC action support for BSBLAN climate entity (#156828)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-02-11 13:27:18 +01:00
epenet
62e1b0118c Add service helper to get config entry (#162068) 2026-02-11 13:20:37 +01:00
Guido Schmitz
b7e9066b9d Add quality scale for devolo Home Control (#147483) 2026-02-11 12:17:02 +01:00
Erik Montnemery
2d6532b8ee Fix deadlock in ReloadServiceHelper (#162775) 2026-02-11 12:14:23 +01:00
Kamil Breguła
ebd1f1b00f Add pagination support for AWS S3 (#162578)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-11 11:27:33 +01:00
jameson_uk
95a1ceb080 feat: add info skills to alexa devices (#162097) 2026-02-11 11:23:07 +01:00
dvdinth
3f9e7d1dba Add IntelliClima integration and tests (#157363)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-11 11:18:26 +01:00
epenet
eab80f78d9 Raise on missing color mode (#162715) 2026-02-11 11:12:52 +01:00
Robert Resch
aa9fdd56ec Bump cryptography to 46.0.5 (#162783) 2026-02-11 11:09:54 +01:00
epenet
c727261f67 Move matter fixture list to a constant (#162776) 2026-02-11 10:47:09 +01:00
jameson_uk
703c62aa74 Bump aioamazondevices to 12.0.0 (#162778) 2026-02-11 10:21:11 +01:00
Tomás Correia
6e1f90228b fix to cloudflare r2 setup screen info (#162677) 2026-02-10 23:43:59 +01:00
LeoXie
3be089d2a5 Add Matter CO alarm state (#162627)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-02-10 23:43:32 +01:00
Noah Husby
692d3d35cc Bump aiostreammagic to 2.12.1 (#162744) 2026-02-10 23:26:20 +01:00
starkillerOG
c52cb8362e Bump reolink-aio to 0.19.0 (#162672) 2026-02-10 23:24:55 +01:00
Boaz Cahlon
93ac215ab4 Add integration for Hegel Music Systems amplifiers (#153867)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-10 22:56:48 +01:00
Michael
f9eb86b50a Improve recognizability of Wi-Fi qr code in FRITZ!Box Tools (#162752) 2026-02-10 21:55:20 +00:00
Christian Lackas
a7f9992a4e Bump homematicip to 2.6.0 (#162702)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-02-10 21:52:09 +00:00
Andreas Jakl
13fde0d135 Bump nrgkick-api to 1.7.1 (#162738) 2026-02-10 19:26:51 +01:00
tronikos
5105c6c50f Add last_changed and last_updated for the Opower statistics (#159101) 2026-02-10 17:08:58 +00:00
Josef Zweck
af152ebe50 Bump onedrive-personal-sdk to 0.1.2 (#162689) 2026-02-10 08:52:29 -08:00
Manu
dea4452e42 Set device entry type and integration type to service in Portainer integration (#162732) 2026-02-10 08:51:03 -08:00
Maikel Punie
af07631d83 migrate velbus config entries (#162565) 2026-02-10 16:14:00 +01:00
theobld-ww
d2ca00ca53 Refactor Watts Vision+ to generic device, in preparation for switch support (#162721)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-10 16:08:30 +01:00
Jeef
bb2f7bdfc4 Bump intellifire4py to 4.3.1 (#162659) 2026-02-10 14:21:58 +00:00
epenet
b1379d9153 Fix flaky lunatone test (#162727) 2026-02-10 15:18:59 +01:00
Anrijs
ea4b286659 Bump aranet lib version to 2.6.0 (#162656)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2026-02-10 16:03:16 +02:00
Norbert Rittel
2d00cb9a29 Improve descriptions of xiaomi_miio.vacuum_clean_segment action (#162698) 2026-02-10 05:47:46 -08:00
Christian Lackas
2ef1a20ae4 Add @lackas as code owner for homematicip_cloud (#162696) 2026-02-10 05:47:11 -08:00
Joost Lekkerkerker
95defddfff Add edenhaus as devcontainer codeowner (#162707) 2026-02-10 05:46:41 -08:00
J. Nick Koston
009bdd91cc Bump aioesphomeapi to 44.0.0 (#162712) 2026-02-10 14:21:56 +01:00
Manu
63bbead41e Add support for attachments from media sources in ntfy notifications (#152329)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-10 13:47:16 +01:00
MoonDevLT
2c9a96b62a Add config entry diagnostics to lunatone (#162406)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-10 13:18:23 +01:00
Brett Adams
ace7fad62a Add exception translations to Teslemetry (#162141)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-10 12:22:59 +01:00
Brett Adams
3c73cc8bad Use icon translations for Teslemetry battery percent entities (#162140)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:15:30 +01:00
Petro31
83c41c265d Update template update to new template entity framework (#162561) 2026-02-10 11:39:29 +01:00
epenet
c8bc5618dc Raise error when light reports invalid supported color modes (#162644) 2026-02-10 11:30:14 +01:00
Brandon Rothweiler
60d770f265 Bump py-aosmith to 1.0.17 (#162685) 2026-02-10 11:17:19 +01:00
joel-bourquard
6f4b9dcad7 Miele: Added support for Plate #5 on Miele KM 7699 (#162503) 2026-02-10 09:34:24 +01:00
ElCruncharino
1bba31f7af Fix AsyncIteratorReader blocking after stream exhaustion (#161731) 2026-02-10 09:21:52 +01:00
Ludovic BOUÉ
4705e584b0 Sort Matter fixture files list (#162693) 2026-02-10 07:41:50 +01:00
Ludovic BOUÉ
80bbe5df6a Add smoke detector test to Matter binary sensor tests (#162638) 2026-02-09 10:36:34 -08:00
Artur Pragacz
88c4d88e06 Simplify subscribe feature websocket in labs (#162646) 2026-02-09 17:05:43 +01:00
dependabot[bot]
718f459026 Bump actions/ai-inference from 2.0.5 to 2.0.6 (#162609)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 16:58:05 +01:00
XHyperDEVX
5c3ddcff3e Make “Reasoning Summary” configurable in OpenAI (#157557)
Co-authored-by: cto-new[bot] <140088366+cto-new[bot]@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-09 16:29:27 +01:00
Ludovic BOUÉ
08acececb2 Add local temperature calibration for all Matter thermostats (#161724)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-02-09 16:09:25 +01:00
epenet
27d6ae2881 Adjust openrgb default color mode handling (#162650) 2026-02-09 16:00:44 +01:00
Brett Adams
5c4d9f4ca4 Fix Tesla Fleet partner registration to use all regions (#162525)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:33:05 +01:00
MarkGodwin
9ece327881 Limit actions on omada controller to one at a time (#162499) 2026-02-09 14:42:37 +01:00
cdnninja
1b0ef3f358 Add drying mode switch to vesync (#161905)
Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com>
2026-02-09 14:30:25 +01:00
Petro31
a5eca0614a Update template weather platform to new template entity framework (#162569) 2026-02-09 14:26:19 +01:00
Aaron Godfrey
7b2509fadb Increase max tasks retrieved per page to prevent timeout (#162587) 2026-02-09 14:19:40 +01:00
epenet
f6e0bc28f4 Raise error when light reports an invalid color_mode (#162620) 2026-02-09 14:18:37 +01:00
Petro31
e87056408e Update template light to new entity framework (#162445) 2026-02-09 14:14:58 +01:00
Petro31
c945f32989 Update template fan platform to the new entity framework (#162328) 2026-02-09 14:13:23 +01:00
Ludovic BOUÉ
8d37917d8b Rename Matter Heiman smoke detector fixture file (#162632) 2026-02-09 14:05:13 +01:00
Artur Pragacz
68cc2dff53 Add subscribe preview feature helper to labs (#161778) 2026-02-09 14:03:02 +01:00
Andrea Turri
45babbca92 Add new Miele mappings (#162544) 2026-02-09 13:53:47 +01:00
Nick Beeuwsaert
b56dcfb7e9 Add sensor state class to eufylife_ble (#162607) 2026-02-09 13:49:58 +01:00
Leonardo Merza
a56114d84a Add slow mode option for SwitchBot curtains (#155272)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-09 13:30:14 +01:00
Allen Porter
de8a26c5b0 Bump grpc to 1.78.0 (#162520) 2026-02-09 13:20:55 +01:00
epenet
48f39524c4 Fix matter light color_mode (#162637) 2026-02-09 13:20:37 +01:00
Aidan Timson
2b4ef312c3 Add translation for MFA code (#162635) 2026-02-09 13:16:18 +01:00
epenet
b4d175b811 Adjust esphome light test (#162633) 2026-02-09 12:48:14 +01:00
epenet
7ff6c2a421 Add missing features in tplink light tests (#162631) 2026-02-09 12:47:56 +01:00
dependabot[bot]
cf0a438f32 Bump j178/prek-action from 1.1.0 to 1.1.1 (#162610)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 11:02:04 +01:00
epenet
9e1bfa3564 Cleanup mired light test (#162622) 2026-02-09 09:55:58 +01:00
Petro31
3c266183e1 Add new template entity framework to event platform (#162228) 2026-02-09 07:54:40 +01:00
epenet
5c5f5d064a Remove legacy fallback in light color_mode property (#162276) 2026-02-09 07:54:07 +01:00
Michael
fc18ec4588 Bump aioimmich to 0.12.0 (#162573)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-08 23:51:24 +01:00
Thomas55555
3fd2fa27e7 Bump aioautomower to 2.7.3 (#162583)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-08 23:49:36 +01:00
Andres Ruiz
cf637f8c2f Update waterfurnace integration to use Coordinator, instead of its own thread. (#161494)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-08 23:39:23 +01:00
Joost Lekkerkerker
228fca9f0c Pin setuptools to 81.0.0 (#162589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-08 23:10:25 +01:00
tan-lawrence
c5ce8998e2 Deprecate unknown fan mode in coolmaster (#161737)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-08 23:05:14 +01:00
Petro31
a4204bf11e Update template lock platform to new template entity framework (#162493) 2026-02-08 23:03:55 +01:00
mettolen
3e44d15fc1 Add diagnostics to Liebherr integration (#162360)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-08 22:52:56 +01:00
Norbert Rittel
4f07d8688c Sentence-case "speech-to-text" in google_cloud (#162534) 2026-02-08 22:50:12 +01:00
Petro31
89fda1a4ae update template number platform to new template entity framework (#162540) 2026-02-08 22:42:11 +01:00
Andres Ruiz
f678e7ef34 Add additional sensors for waterfurnace integration (#162581) 2026-02-08 22:38:21 +01:00
Petro31
24e8208deb Update template select platform to new template entity framework (#162543) 2026-02-08 22:27:49 +01:00
Petro31
3c66a1b35d Update template vacuum platform to new template entity framework (#162564) 2026-02-08 22:27:21 +01:00
Petro31
5a2299e8b6 Update template switch platform to new template entity framework (#162556) 2026-02-08 22:25:38 +01:00
Noah Husby
8087953b90 Bump aiostreammagic to 2.12.0 (#162570) 2026-02-08 21:53:29 +01:00
Thomas55555
77a15b44c9 Increase polling in Husqvarna Automower (#162582) 2026-02-08 21:50:42 +01:00
hanwg
2177b494b9 Fix config flow bug for Telegram bot (#162555) 2026-02-08 21:32:51 +01:00
Peter Grauvogel
10497c2bf4 Fix Green Planet Energy price unit conversion (#162511) 2026-02-08 21:07:32 +01:00
Petro31
e7fd744941 Update template sensor platform to new template entity framework (#162554) 2026-02-08 21:01:31 +01:00
Elias Wernicke
b9bfbc9e98 Validate conversation_command in start timer intent (#149915)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-02-08 19:56:07 +01:00
Robert Svensson
ba6f1343cc Add regression testing to Axis OUI support list (#162508) 2026-02-08 19:19:09 +01:00
Daniel Hjelseth Høyer
5b8ba86fa8 string
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-07 12:51:19 +01:00
Daniel Hjelseth Høyer
0bdb653b55 string
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-07 08:43:26 +01:00
Daniel Hjelseth Høyer
913fd3a981 Merge branch 'dev' into homevolt 2026-02-07 07:21:52 +01:00
Daniel Hjelseth Høyer
11c4507a16 fix: update snapshots
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-07 07:18:03 +01:00
Daniel Hjelseth Høyer
f8e4d7d97a fix: re-raise authentication errors in homevolt library
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-07 07:15:51 +01:00
Daniel Hjelseth Høyer
434d032abd Rename type
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 21:57:00 +01:00
Daniel Hjelseth Høyer
33ac5b78d5 Rename type
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 21:26:40 +01:00
Daniel Hjelseth Høyer
3b60ebd7f7 Rename type
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 19:50:20 +01:00
Daniel Hjelseth Høyer
ec34a209ad tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 18:31:50 +01:00
Daniel Hjelseth Høyer
83f3b4a170 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 17:42:50 +01:00
Daniel Hjelseth Høyer
c3ab65b5a5 tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 16:50:05 +01:00
Daniel Hjelseth Høyer
0237a11d4b tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 16:49:56 +01:00
Daniel Hjelseth Høyer
2b9854e412 tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 16:49:40 +01:00
Daniel Hjelseth Høyer
9c780246aa tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 16:36:49 +01:00
Daniel Hjelseth Høyer
314ebc90ff tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-06 15:29:25 +01:00
Daniel Hjelseth Høyer
05c4c15d1f Update tests/components/homevolt/test_config_flow.py
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-06 14:24:26 +01:00
Daniel Hjelseth Høyer
3f2c71ad6b Update homeassistant/components/homevolt/manifest.json
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-06 14:23:56 +01:00
Daniel Hjelseth Høyer
c9670b4bd2 Refactor
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-04 21:29:35 +01:00
Daniel Hjelseth Høyer
8e16b1004e homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-04 18:44:41 +01:00
Daniel Hjelseth Høyer
3a32f87a7f Merge branch 'dev' into homevolt 2026-02-04 10:27:42 +01:00
Daniel Hjelseth Høyer
92eb2406be homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-04 07:15:12 +01:00
Daniel Hjelseth Høyer
492c2cec3e homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-04 06:59:51 +01:00
Daniel Hjelseth Høyer
b7c6e8d68a Apply suggestions from code review
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-02 21:51:43 +01:00
Daniel Hjelseth Høyer
205bc0456f Merge branch 'dev' into homevolt 2026-01-20 16:24:32 +01:00
Daniel Hjelseth Høyer
5aa32491c8 Merge branch 'dev' into homevolt 2026-01-15 16:25:46 +01:00
Daniel Hjelseth Høyer
dc2cd2246b Merge branch 'dev' into homevolt 2026-01-15 07:06:51 +01:00
Daniel Hjelseth Høyer
181037820b Merge branch 'dev' into homevolt 2026-01-14 21:05:45 +01:00
Daniel Hjelseth Høyer
6cf15bf70c homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 19:09:37 +01:00
Daniel Hjelseth Høyer
5a34c31e42 Merge branch 'dev' into homevolt 2026-01-14 18:30:20 +01:00
Daniel Hjelseth Høyer
9dcc86f12e homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 18:03:21 +01:00
Daniel Hjelseth Høyer
04429a6eef homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 17:40:51 +01:00
Daniel Hjelseth Høyer
51e2506afb homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 16:41:08 +01:00
Daniel Hjelseth Høyer
e49e5c7c40 Merge branch 'dev' into homevolt 2026-01-14 14:41:26 +01:00
Daniel Hjelseth Høyer
b8dfc523da homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 14:36:43 +01:00
Daniel Hjelseth Høyer
a25fbf57ef Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 17:20:27 +01:00
Daniel Hjelseth Høyer
dac22002b0 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:53:07 +01:00
Daniel Hjelseth Høyer
e61f00a3ae Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:15:56 +01:00
Daniel Hjelseth Høyer
14a67c6b5d Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:46:49 +01:00
Daniel Hjelseth Høyer
90ae81f02b Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:39:46 +01:00
Daniel Hjelseth Høyer
a741f214da Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:35:53 +01:00
Daniel Hjelseth Høyer
21d0bd3ce2 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:22:32 +01:00
Daniel Hjelseth Høyer
d9c1f4850a Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:09:50 +01:00
Daniel Hjelseth Høyer
335994af7e Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 09:44:06 +01:00
463 changed files with 22181 additions and 6967 deletions

View File

@@ -254,7 +254,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@564dda4cfa5e96aafdc4a5696c4bf7b46baae5ac # v1.1.0
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -287,6 +287,7 @@ homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
homeassistant.components.integration.*
homeassistant.components.intelliclima.*
homeassistant.components.intent.*
homeassistant.components.intent_script.*
homeassistant.components.ios.*
@@ -363,7 +364,6 @@ homeassistant.components.my.*
homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.*
homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.*

16
CODEOWNERS generated
View File

@@ -15,7 +15,7 @@
.yamllint @home-assistant/core
pyproject.toml @home-assistant/core
requirements_test.txt @home-assistant/core
/.devcontainer/ @home-assistant/core
/.devcontainer/ @home-assistant/core @edenhaus
/.github/ @home-assistant/core
/.vscode/ @home-assistant/core
/homeassistant/*.py @home-assistant/core
@@ -672,6 +672,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
/homeassistant/components/hegel/ @boazca
/tests/components/hegel/ @boazca
/homeassistant/components/heos/ @andrewsayre
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
@@ -715,8 +717,10 @@ build.json @home-assistant/supervisor
/tests/components/homekit_controller/ @Jc2k @bdraco
/homeassistant/components/homematic/ @pvizeli
/tests/components/homematic/ @pvizeli
/homeassistant/components/homematicip_cloud/ @hahn-th
/tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas
/tests/components/homematicip_cloud/ @hahn-th @lackas
/homeassistant/components/homevolt/ @danielhiversen
/tests/components/homevolt/ @danielhiversen
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
@@ -802,6 +806,8 @@ build.json @home-assistant/supervisor
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
/tests/components/intelliclima/ @dvdinth
/homeassistant/components/intellifire/ @jeeftor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
@@ -1078,8 +1084,8 @@ build.json @home-assistant/supervisor
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul

View File

@@ -29,3 +29,24 @@ COUNTRY_DOMAINS = {
CATEGORY_SENSORS = "sensors"
CATEGORY_NOTIFICATIONS = "notifications"
# Map service translation keys to Alexa API
INFO_SKILLS_MAPPING = {
"calendar_today": "Alexa.Calendar.PlayToday",
"calendar_tomorrow": "Alexa.Calendar.PlayTomorrow",
"calendar_next": "Alexa.Calendar.PlayNext",
"date": "Alexa.Date.Play",
"time": "Alexa.Time.Play",
"national_news": "Alexa.News.NationalNews",
"flash_briefing": "Alexa.FlashBriefing.Play",
"traffic": "Alexa.Traffic.Play",
"weather": "Alexa.Weather.Play",
"cleanup": "Alexa.CleanUp.Play",
"good_morning": "Alexa.GoodMorning.Play",
"sing_song": "Alexa.SingASong.Play",
"fun_fact": "Alexa.FunFact.Play",
"tell_joke": "Alexa.Joke.Play",
"tell_story": "Alexa.TellStory.Play",
"im_home": "Alexa.ImHome.Play",
"goodnight": "Alexa.GoodNight.Play",
}

View File

@@ -1,5 +1,8 @@
{
"services": {
"send_info_skill": {
"service": "mdi:information"
},
"send_sound": {
"service": "mdi:cast-audio"
},

View File

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

View File

@@ -1,5 +1,6 @@
"""Support for services."""
from aioamazondevices.const.metadata import ALEXA_INFO_SKILLS
from aioamazondevices.const.sounds import SOUNDS_LIST
import voluptuous as vol
@@ -9,13 +10,15 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
ATTR_INFO_SKILL = "info_skill"
SERVICE_TEXT_COMMAND = "send_text_command"
SERVICE_SOUND_NOTIFICATION = "send_sound"
SERVICE_INFO_SKILL = "send_info_skill"
SCHEMA_SOUND_SERVICE = vol.Schema(
{
@@ -29,6 +32,12 @@ SCHEMA_CUSTOM_COMMAND = vol.Schema(
vol.Required(ATTR_DEVICE_ID): cv.string,
}
)
SCHEMA_INFO_SKILL = vol.Schema(
{
vol.Required(ATTR_INFO_SKILL): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.string,
}
)
@callback
@@ -86,6 +95,17 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], value
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -98,6 +118,11 @@ async def async_send_text_command(call: ServiceCall) -> None:
await _async_execute_action(call, ATTR_TEXT_COMMAND)
async def async_send_info_skill(call: ServiceCall) -> None:
"""Send an info skill command to a AmazonDevice."""
await _async_execute_action(call, ATTR_INFO_SKILL)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amazon Devices integration."""
@@ -112,5 +137,10 @@ def async_setup_services(hass: HomeAssistant) -> None:
async_send_text_command,
SCHEMA_CUSTOM_COMMAND,
),
(
SERVICE_INFO_SKILL,
async_send_info_skill,
SCHEMA_INFO_SKILL,
),
):
hass.services.async_register(DOMAIN, service_name, method, schema=schema)

View File

@@ -67,3 +67,36 @@ send_sound:
- squeaky_12
- zap_01
translation_key: sound
send_info_skill:
fields:
device_id:
required: true
selector:
device:
integration: alexa_devices
info_skill:
required: true
example: date
default: date
selector:
select:
options:
- calendar_today
- calendar_tomorrow
- calendar_next
- date
- time
- national_news
- flash_briefing
- traffic
- weather
- cleanup
- good_morning
- sing_song
- fun_fact
- tell_joke
- tell_story
- im_home
- goodnight
translation_key: info_skill

View File

@@ -102,11 +102,35 @@
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
"invalid_info_skill_value": {
"message": "Invalid info skill {info_skill} specified"
},
"invalid_sound_value": {
"message": "Invalid sound {sound} specified"
}
},
"selector": {
"info_skill": {
"options": {
"calendar_next": "Calendar: Next event",
"calendar_today": "Calendar: Today's Calendar",
"calendar_tomorrow": "Calendar: Tomorrow's Calendar",
"cleanup": "Encourage me to clean up",
"date": "Date",
"flash_briefing": "Flash Briefing",
"fun_fact": "Tell me a fun fact",
"good_morning": "Good morning",
"goodnight": "Wish me a good night",
"im_home": "Welcome me home",
"national_news": "National News",
"sing_song": "Sing a song",
"tell_joke": "Tell me a joke",
"tell_story": "Tell me a story",
"time": "Time",
"traffic": "Traffic",
"weather": "Weather"
}
},
"sound": {
"options": {
"air_horn_03": "Air horn",
@@ -154,6 +178,20 @@
}
},
"services": {
"send_info_skill": {
"description": "Sends an info skill command to a device",
"fields": {
"device_id": {
"description": "[%key:component::alexa_devices::common::device_id_description%]",
"name": "Device"
},
"info_skill": {
"description": "The info skill command to send.",
"name": "Alexa info skill command"
}
},
"name": "Send info skill command"
},
"send_sound": {
"description": "Sends a sound to a device",
"fields": {

View File

@@ -3,7 +3,6 @@
from amberelectric.models.channel import ChannelType
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
@@ -13,6 +12,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import service
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonValueType
@@ -37,23 +37,6 @@ GET_FORECASTS_SCHEMA = vol.Schema(
)
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
"""Get the Amber config entry."""
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": config_entry_id},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return entry
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
"""Return an array of forecasts."""
results: list[JsonValueType] = []
@@ -109,7 +92,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
channel_type = call.data[ATTR_CHANNEL_TYPE]
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
entry: AmberConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
coordinator = entry.runtime_data
forecasts = get_forecasts(channel_type, coordinator.data)
return {"forecasts": forecasts}

View File

@@ -25,12 +25,6 @@
"exceptions": {
"channel_not_found": {
"message": "There is no {channel_type} channel at this site."
},
"integration_not_found": {
"message": "Config entry \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
}
},
"selector": {

View File

@@ -73,31 +73,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
started = False
async def _async_handle_labs_update(
event: Event[labs.EventLabsUpdatedData],
event_data: labs.EventLabsUpdatedData,
) -> None:
"""Handle labs feature toggle."""
await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]})
await analytics.save_preferences({ATTR_SNAPSHOTS: event_data["enabled"]})
if started:
await analytics.async_schedule()
@callback
def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool:
"""Filter labs events for this integration's snapshot feature."""
return (
event_data["domain"] == DOMAIN
and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE
)
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
hass.bus.async_listen(
labs.EVENT_LABS_UPDATED,
_async_handle_labs_update,
event_filter=_async_labs_event_filter,
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)

View File

@@ -491,22 +491,24 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"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
},
f" {zone_home.attributes[ATTR_LONGITUDE]})?",
}
],
max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]),
output_config={
"format": {
"type": "json_schema",
"schema": {
**convert(location_schema),
"additionalProperties": False,
},
}
},
)
_LOGGER.debug("Model response: %s", response.content)
location_data = location_schema(
json.loads(
"{"
+ "".join(
"".join(
block.text
for block in response.content
if isinstance(block, anthropic.types.TextBlock)

View File

@@ -56,6 +56,15 @@ NON_ADAPTIVE_THINKING_MODELS = [
"claude-3",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",

View File

@@ -20,6 +20,7 @@ from anthropic.types import (
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta,
JSONOutputFormatParam,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
@@ -94,6 +95,7 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
# Max number of back and forth with the LLM to generate a response
@@ -697,8 +699,25 @@ class AnthropicBaseLLMEntity(Entity):
)
if structure and structure_name:
structure_name = slugify(structure_name)
if model_args["thinking"]["type"] == "disabled":
if not model.startswith(tuple(UNSUPPORTED_STRUCTURED_OUTPUT_MODELS)):
# Native structured output for those models who support it.
structure_name = None
model_args.setdefault("output_config", OutputConfigParam())[
"format"
] = JSONOutputFormatParam(
type="json_schema",
schema={
**convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
"additionalProperties": False,
},
)
elif model_args["thinking"]["type"] == "disabled":
structure_name = slugify(structure_name)
if not tools:
# Simplest case: no tools and no extended thinking
# Add a tool and force its use
@@ -718,6 +737,7 @@ class AnthropicBaseLLMEntity(Entity):
# force tool use or disable text responses, so we add a hint to the
# system prompt instead. With extended thinking, the model should be
# smart enough to use the tool.
structure_name = slugify(structure_name)
model_args["tool_choice"] = ToolChoiceAutoParam(
type="auto",
)
@@ -725,22 +745,24 @@ class AnthropicBaseLLMEntity(Entity):
model_args["system"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
text=f"Claude MUST use the '{structure_name}' tool to provide "
"the final answer instead of plain text.",
)
)
tools.append(
ToolParam(
name=structure_name,
description="Use this tool to reply to the user",
input_schema=convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
if structure_name:
tools.append(
ToolParam(
name=structure_name,
description="Use this tool to reply to the user",
input_schema=convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
)
)
)
if tools:
model_args["tools"] = tools
@@ -761,7 +783,7 @@ class AnthropicBaseLLMEntity(Entity):
_transform_stream(
chat_log,
stream,
output_tool=structure_name if structure else None,
output_tool=structure_name or None,
),
)
]

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.16"]
"requirements": ["py-aosmith==1.0.17"]
}

View File

@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aranet4==2.5.1"]
"requirements": ["aranet4==2.6.0"]
}

View File

@@ -30,6 +30,9 @@
"title": "Set up one-time password delivered by notify component"
},
"setup": {
"data": {
"code": "Code"
},
"description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:",
"title": "Verify setup"
}
@@ -42,6 +45,9 @@
},
"step": {
"init": {
"data": {
"code": "Code"
},
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.",
"title": "Set up two-factor authentication using TOTP"
}

View File

@@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.components.labs import async_listen as async_labs_listen
from homeassistant.components.labs import async_subscribe_preview_feature
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
@@ -386,14 +386,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
schema=vol.Schema({vol.Optional(CONF_ID): str}),
)
@callback
def new_triggers_conditions_listener() -> None:
async def new_triggers_conditions_listener(
_event_data: labs.EventLabsUpdatedData,
) -> None:
"""Handle new_triggers_conditions flag change."""
hass.async_create_task(
reload_helper.execute_service(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
)
await reload_helper.execute_service(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
async_labs_listen(
async_subscribe_preview_feature(
hass,
DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,

View File

@@ -297,14 +297,14 @@ class S3BackupAgent(BackupAgent):
return self._backup_cache
backups = {}
response = await self._client.list_objects_v2(Bucket=self._bucket)
# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
for metadata_file in metadata_files:
try:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from bsblan import BSBLANError
from bsblan import BSBLANError, get_hvac_action_category
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -13,6 +13,7 @@ from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE
@@ -128,6 +129,15 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
return try_parse_enum(HVACMode, hvac_mode_value)
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
action = self.coordinator.data.state.hvac_action
if not action or not isinstance(action.value, int):
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==4.1.0"],
"requirements": ["python-bsblan==4.2.0"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -9,10 +9,11 @@ from bsblan import BSBLANError, SetHotWaterParam
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_OFF,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@@ -24,14 +25,16 @@ from .entity import BSBLanDualCoordinatorEntity
PARALLEL_UPDATES = 1
# Mapping between BSBLan and HA operation modes
OPERATION_MODES = {
"Eco": STATE_ECO, # Energy saving mode
"Off": STATE_OFF, # Protection mode
"On": STATE_ON, # Continuous comfort mode
# Mapping between BSBLan operating mode values and HA operation modes
BSBLAN_TO_HA_OPERATION_MODE: dict[int, str] = {
0: STATE_OFF, # Protection mode
1: STATE_PERFORMANCE, # Continuous comfort mode
2: STATE_ECO, # Eco/automatic mode
}
OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
HA_TO_BSBLAN_OPERATION_MODE: dict[str, int] = {
v: k for k, v in BSBLAN_TO_HA_OPERATION_MODE.items()
}
async def async_setup_entry(
@@ -63,13 +66,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
def __init__(self, data: BSBLanData) -> None:
"""Initialize BSBLAN water heater."""
super().__init__(data.fast_coordinator, data.slow_coordinator, data)
self._attr_unique_id = format_mac(data.device.MAC)
self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys())
self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
# Set temperature unit
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@@ -110,8 +114,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Return current operation."""
if self.coordinator.data.dhw.operating_mode is None:
return None
current_mode = self.coordinator.data.dhw.operating_mode.desc
return OPERATION_MODES.get(current_mode)
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
current_mode_value = self.coordinator.data.dhw.operating_mode.value
if isinstance(current_mode_value, int):
return BSBLAN_TO_HA_OPERATION_MODE.get(current_mode_value)
return None
@property
def current_temperature(self) -> float | None:
@@ -144,10 +151,12 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode)
# Base class validates operation_mode is in operation_list before calling
bsblan_mode = HA_TO_BSBLAN_OPERATION_MODE[operation_mode]
try:
# Send numeric value as string - BSB-LAN API expects numeric mode values
await self.coordinator.client.set_hot_water(
SetHotWaterParam(operating_mode=bsblan_mode)
SetHotWaterParam(operating_mode=str(bsblan_mode))
)
except BSBLANError as err:
raise HomeAssistantError(
@@ -156,3 +165,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
) from err
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the water heater on."""
await self.async_set_operation_mode(STATE_PERFORMANCE)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the water heater off."""
await self.async_set_operation_mode(STATE_OFF)

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.11.0"],
"requirements": ["aiostreammagic==2.12.1"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}

View File

@@ -19,11 +19,11 @@
"secret_access_key": "Secret access key"
},
"data_description": {
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
"access_key_id": "Access key ID to connect to Cloudflare R2",
"bucket": "Bucket must already exist and be writable by the provided credentials.",
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Cloudflare documentation]({auth_docs_url})"
},
"title": "Add Cloudflare R2 bucket"
}

View File

@@ -144,7 +144,7 @@ class ComelitAlarmEntity(
"""Update state after action."""
self._area.human_status = area_state
self._area.armed = armed
await self.async_update_ha_state()
self.async_write_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""

View File

@@ -70,6 +70,10 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
_attr_name = None
# TODO(2026.7.0): When support for unknown fan speeds is removed, delete this variable.
# Holds unknown fan speeds we have already warned about.
warned_unknown_fan_speeds: set[str] = set()
def __init__(
self,
coordinator: CoolmasterDataUpdateCoordinator,
@@ -125,8 +129,20 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
def fan_mode(self):
"""Return the fan setting."""
# Normalize to lowercase for lookup, and pass unknown values through.
return CM_TO_HA_FAN.get(self._unit.fan_speed.lower(), self._unit.fan_speed)
# Normalize to lowercase for lookup, and pass unknown lowercase values through.
fan_speed_lower = self._unit.fan_speed.lower()
if fan_speed_lower not in CM_TO_HA_FAN:
# TODO(2026.7.0): Stop supporting unknown fan speeds.
if fan_speed_lower not in CoolmasterClimate.warned_unknown_fan_speeds:
CoolmasterClimate.warned_unknown_fan_speeds.add(fan_speed_lower)
_LOGGER.warning(
"Detected unknown fan speed value from HVAC unit: %s. "
"Support for unknown fan speeds will be removed in 2026.7.0",
fan_speed_lower,
)
return fan_speed_lower
return CM_TO_HA_FAN[fan_speed_lower]
@property
def fan_modes(self):

View File

@@ -8,6 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
"quality_scale": "silver",
"requirements": ["devolo-home-control-api==0.19.0"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}

View File

@@ -0,0 +1,92 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
This integration does not poll.
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
The information provided by the discovery is not used for more than displaying the integration in the UI.
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: |
This integration does not define custom icons. All entities use device class icons.
reconfiguration-flow:
status: exempt
comment: |
No configuration besides credentials.
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices: done
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: |
Integration does not use a web session.
strict-typing: done

View File

@@ -10,7 +10,6 @@ from typing import Final
from easyenergy import Electricity, Gas, VatOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -19,7 +18,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import selector
from homeassistant.helpers import selector, service
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -88,28 +87,9 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
"""Get the coordinator from the entry."""
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry(
entry_id
entry: EasyEnergyConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY]
)
if not entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_config_entry",
translation_placeholders={
"config_entry": entry_id,
},
)
if entry.state != ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unloaded_config_entry",
translation_placeholders={
"config_entry": entry.title,
},
)
return entry.runtime_data

View File

@@ -44,14 +44,8 @@
}
},
"exceptions": {
"invalid_config_entry": {
"message": "Invalid config entry provided. Got {config_entry}"
},
"invalid_date": {
"message": "Invalid date provided. Got {date}"
},
"unloaded_config_entry": {
"message": "Invalid config entry provided. {config_entry} is not loaded."
}
},
"services": {

View File

@@ -6,6 +6,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
@@ -15,6 +16,12 @@ from homeassistant.helpers.selector import (
from . import dongle
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
MANUAL_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE): cv.string,
}
)
class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle the enOcean config flows."""
@@ -49,17 +56,14 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Propose a list of detected dongles."""
errors = {}
if user_input is not None:
if user_input[CONF_DEVICE] == self.MANUAL_PATH_VALUE:
return await self.async_step_manual()
if await self.validate_enocean_conf(user_input):
return self.create_enocean_entry(user_input)
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
return await self.async_step_manual(user_input)
devices = await self.hass.async_add_executor_job(dongle.detect)
if len(devices) == 0:
return await self.async_step_manual(user_input)
return await self.async_step_manual()
devices.append(self.MANUAL_PATH_VALUE)
return self.async_show_form(
@@ -75,26 +79,21 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
)
}
),
errors=errors,
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Request manual USB dongle path."""
default_value = None
errors = {}
if user_input is not None:
if await self.validate_enocean_conf(user_input):
return self.create_enocean_entry(user_input)
default_value = user_input[CONF_DEVICE]
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
return self.async_show_form(
step_id="manual",
data_schema=vol.Schema(
{vol.Required(CONF_DEVICE, default=default_value): str}
),
data_schema=self.add_suggested_values_to_schema(MANUAL_SCHEMA, user_input),
errors=errors,
)

View File

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

View File

@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass
from homeassistant.core import HomeAssistant, callback
@@ -47,6 +48,7 @@ class EufyLifeSensorEntity(SensorEntity):
"""Representation of an EufyLife sensor."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, data: EufyLifeData) -> None:
"""Initialize the weight sensor entity."""

View File

@@ -65,10 +65,10 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
super().__init__(avm_wrapper, device_friendly_name)
ImageEntity.__init__(self, hass)
async def _fetch_image(self) -> bytes:
def _fetch_image(self) -> bytes:
"""Fetch the QR code from the Fritz!Box."""
qr_stream: BytesIO = await self.hass.async_add_executor_job(
self._avm_wrapper.fritz_guest_wifi.get_wifi_qr_code, "png"
qr_stream: BytesIO = self._avm_wrapper.fritz_guest_wifi.get_wifi_qr_code(
"png", border=2
)
qr_bytes = qr_stream.getvalue()
_LOGGER.debug("fetched %s bytes", len(qr_bytes))
@@ -77,13 +77,15 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
async def async_added_to_hass(self) -> None:
"""Fetch and set initial data and state."""
self._current_qr_bytes = await self._fetch_image()
self._current_qr_bytes = await self.hass.async_add_executor_job(
self._fetch_image
)
self._attr_image_last_updated = dt_util.utcnow()
async def async_update(self) -> None:
"""Update the image entity data."""
try:
qr_bytes = await self._fetch_image()
qr_bytes = await self.hass.async_add_executor_job(self._fetch_image)
except RequestException:
self._current_qr_bytes = None
self._attr_image_last_updated = None

View File

@@ -23,7 +23,7 @@
"pitch": "Default pitch of the voice",
"profiles": "Default audio profiles",
"speed": "Default rate/speed of the voice",
"stt_model": "Speech-to-Text model",
"stt_model": "Speech-to-text model",
"text_type": "Default text type",
"voice": "Default voice name (overrides language and gender)"
}

View File

@@ -80,7 +80,10 @@ class GoogleGenerativeAITaskEntity(
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
chat_log,
task.structure,
default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS,
max_iterations=1000,
)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):

View File

@@ -486,6 +486,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
chat_log: conversation.ChatLog,
structure: vol.Schema | None = None,
default_max_tokens: int | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -602,7 +603,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
for _iteration in range(max_iterations):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request

View File

@@ -18,8 +18,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN, UPLOAD_SCOPE
from .coordinator import GooglePhotosConfigEntry
@@ -80,15 +80,10 @@ def _read_file_contents(
async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
config_entry: GooglePhotosConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
raise HomeAssistantError(

View File

@@ -62,18 +62,12 @@
"filename_is_not_image": {
"message": "`{filename}` is not an image"
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"missing_upload_permission": {
"message": "Home Assistant was not granted permission to upload to Google Photos"
},
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"not_loaded": {
"message": "{target} is not loaded."
},
"upload_error": {
"message": "Failed to upload content: {message}"
}

View File

@@ -12,7 +12,6 @@ from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import (
HomeAssistant,
@@ -21,8 +20,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonObjectType
@@ -60,9 +59,9 @@ get_SHEET_SERVICE_SCHEMA = vol.All(
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = service.open_by_key(entry.unique_id)
sheet = client.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
@@ -90,9 +89,9 @@ def _get_from_sheet(
call: ServiceCall, entry: GoogleSheetsConfigEntry
) -> JsonObjectType:
"""Run get in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = service.open_by_key(entry.unique_id)
sheet = client.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
@@ -106,27 +105,18 @@ def _get_from_sheet(
async def _async_append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
entry: GoogleSheetsConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
)
if not entry or not hasattr(entry, "runtime_data"):
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
await entry.runtime_data.async_ensure_token_valid()
await call.hass.async_add_executor_job(_append_to_sheet, call, entry)
async def _async_get_from_sheet(call: ServiceCall) -> ServiceResponse:
"""Get lines of data from a Google Sheets document."""
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
call.data[DATA_CONFIG_ENTRY]
entry: GoogleSheetsConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
)
if entry is None:
raise ServiceValidationError(
f"Invalid config entry id: {call.data[DATA_CONFIG_ENTRY]}"
)
if entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(f"Config entry {entry.entry_id} is not loaded")
await entry.runtime_data.async_ensure_token_valid()
return await call.hass.async_add_executor_job(_get_from_sheet, call, entry)

View File

@@ -43,7 +43,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
translation_key="highest_price_today",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
suggested_display_precision=4,
value_fn=lambda api, data: api.get_highest_price_today(data),
value_fn=lambda api, data: (
price / 100
if (price := api.get_highest_price_today(data)) is not None
else None
),
),
GreenPlanetEnergySensorEntityDescription(
key="gpe_highest_price_time",
@@ -61,7 +65,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
suggested_display_precision=4,
translation_placeholders={"time_range": "(06:00-18:00)"},
value_fn=lambda api, data: api.get_lowest_price_day(data),
value_fn=lambda api, data: (
price / 100
if (price := api.get_lowest_price_day(data)) is not None
else None
),
),
GreenPlanetEnergySensorEntityDescription(
key="gpe_lowest_price_day_time",
@@ -80,7 +88,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
suggested_display_precision=4,
translation_placeholders={"time_range": "(18:00-06:00)"},
value_fn=lambda api, data: api.get_lowest_price_night(data),
value_fn=lambda api, data: (
price / 100
if (price := api.get_lowest_price_night(data)) is not None
else None
),
),
GreenPlanetEnergySensorEntityDescription(
key="gpe_lowest_price_night_time",
@@ -98,7 +110,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
translation_key="current_price",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
suggested_display_precision=4,
value_fn=lambda api, data: api.get_current_price(data, dt_util.now().hour),
value_fn=lambda api, data: (
price / 100
if (price := api.get_current_price(data, dt_util.now().hour)) is not None
else None
),
),
]

View File

@@ -0,0 +1,72 @@
"""The Hegel integration."""
from __future__ import annotations
import logging
from hegel_ip_client import HegelClient
from hegel_ip_client.exceptions import HegelConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DEFAULT_PORT
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
type HegelConfigEntry = ConfigEntry[HegelClient]
async def async_setup_entry(hass: HomeAssistant, entry: HegelConfigEntry) -> bool:
"""Set up the Hegel integration."""
host = entry.data[CONF_HOST]
# Create and test client connection
client = HegelClient(host, DEFAULT_PORT)
try:
# Test connection before proceeding with setup
await client.start()
await client.ensure_connected(timeout=10.0)
_LOGGER.debug("Successfully connected to Hegel at %s:%s", host, DEFAULT_PORT)
except (HegelConnectionError, TimeoutError, OSError) as err:
_LOGGER.error(
"Failed to connect to Hegel at %s:%s: %s", host, DEFAULT_PORT, err
)
await client.stop() # Clean up
raise ConfigEntryNotReady(
f"Unable to connect to Hegel amplifier at {host}:{DEFAULT_PORT}"
) from err
# Store client in runtime_data
entry.runtime_data = client
async def _async_close_client(event):
await client.stop()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_client)
)
# Forward setup to supported platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HegelConfigEntry) -> bool:
"""Unload a Hegel config entry and stop active client connection."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
client = entry.runtime_data
_LOGGER.debug("Stopping Hegel client for %s", entry.title)
try:
await client.stop()
except (HegelConnectionError, OSError) as err:
_LOGGER.warning("Error while stopping Hegel client: %s", err)
return unload_ok

View File

@@ -0,0 +1,154 @@
"""Config flow for Hegel integration."""
from __future__ import annotations
import logging
from typing import Any
from hegel_ip_client import HegelClient
from hegel_ip_client.exceptions import HegelConnectionError
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import CONF_MODEL, DEFAULT_PORT, DOMAIN, MODEL_INPUTS
_LOGGER = logging.getLogger(__name__)
class HegelConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Hegel amplifiers."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
self._name: str | None = None
self._model: str | None = None
async def _async_try_connect(self, host: str) -> bool:
"""Try to connect to the Hegel amplifier using the library."""
client = HegelClient(host, DEFAULT_PORT)
try:
await client.start()
await client.ensure_connected(timeout=5.0)
except HegelConnectionError, TimeoutError, OSError:
return False
else:
return True
finally:
await client.stop()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle manual setup by the user."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
# Prevent duplicate entries by host
self._async_abort_entries_match({CONF_HOST: host})
if not await self._async_try_connect(host):
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"Hegel {user_input[CONF_MODEL]}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_MODEL): vol.In(list(MODEL_INPUTS.keys())),
}
),
errors=errors,
)
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle SSDP discovery."""
upnp = discovery_info.upnp or {}
# Get host from presentationURL or ssdp_location
url = upnp.get("presentationURL") or discovery_info.ssdp_location
if not url:
return self.async_abort(reason="no_host_found")
host = URL(url).host
if not host:
return self.async_abort(reason="no_host_found")
# Use UDN as unique id (device UUID)
unique_id = discovery_info.ssdp_udn
if not unique_id:
return self.async_abort(reason="no_host_found")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
# Test connection before showing confirmation
if not await self._async_try_connect(host):
return self.async_abort(reason="cannot_connect")
# Get device info
friendly_name = upnp.get("friendlyName", f"Hegel {host}")
suggested_model = upnp.get("modelName") or ""
model_default = next(
(m for m in MODEL_INPUTS if suggested_model.upper().startswith(m.upper())),
None,
)
self._host = host
self._name = friendly_name
self._model = model_default
self.context.update(
{
"title_placeholders": {"name": friendly_name},
}
)
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle discovery confirmation - user can change model if needed."""
assert self._host is not None
assert self._name is not None
if user_input is not None:
return self.async_create_entry(
title=self._name,
data={
CONF_HOST: self._host,
CONF_MODEL: user_input[CONF_MODEL],
},
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Required(
CONF_MODEL,
default=self._model or list(MODEL_INPUTS.keys())[0],
): vol.In(list(MODEL_INPUTS.keys())),
}
),
description_placeholders={
"host": self._host,
"name": self._name,
},
)

View File

@@ -0,0 +1,92 @@
"""Constants for the Hegel integration."""
DOMAIN = "hegel"
DEFAULT_PORT = 50001
CONF_MODEL = "model"
CONF_MAX_VOLUME = "max_volume" # 1.0 means amp's internal max
HEARTBEAT_TIMEOUT_MINUTES = 3
MODEL_INPUTS = {
"Röst": [
"Balanced",
"Analog 1",
"Analog 2",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
"H95": [
"Analog 1",
"Analog 2",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
"H120": [
"Balanced",
"Analog 1",
"Analog 2",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
"H190": [
"Balanced",
"Analog 1",
"Analog 2",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
"H190V": [
"XLR",
"Analog 1",
"Analog 2",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
"Phono",
],
"H390": [
"XLR",
"Analog 1",
"Analog 2",
"BNC",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
"H590": [
"XLR 1",
"XLR 2",
"Analog 1",
"Analog 2",
"BNC",
"Coaxial",
"Optical 1",
"Optical 2",
"Optical 3",
"USB",
"Network",
],
}

View File

@@ -0,0 +1,18 @@
{
"domain": "hegel",
"name": "Hegel Amplifier",
"codeowners": ["@boazca"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hegel/",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["hegel_ip_client"],
"quality_scale": "silver",
"requirements": ["hegel-ip-client==0.1.4"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
"manufacturer": "Hegel"
}
]
}

View File

@@ -0,0 +1,343 @@
"""Hegel media player platform."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import contextlib
from datetime import timedelta
import logging
from typing import Any
from hegel_ip_client import (
COMMANDS,
HegelClient,
apply_state_changes,
parse_reply_message,
)
from hegel_ip_client.exceptions import HegelConnectionError
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import HegelConfigEntry
from .const import CONF_MODEL, DOMAIN, HEARTBEAT_TIMEOUT_MINUTES, MODEL_INPUTS
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: HegelConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hegel media player from a config entry."""
model = entry.data[CONF_MODEL]
unique_id = entry.unique_id or entry.entry_id
# map inputs (source_map)
source_map: dict[int, str] = (
dict(enumerate(MODEL_INPUTS[model], start=1)) if model in MODEL_INPUTS else {}
)
# Use the client from the config entry's runtime_data (already connected)
client = entry.runtime_data
# Create entity
media = HegelMediaPlayer(
entry,
client,
source_map,
unique_id,
)
async_add_entities([media])
class HegelMediaPlayer(MediaPlayerEntity):
"""Hegel amplifier entity."""
_attr_should_poll = False
_attr_name = None
_attr_has_entity_name = True
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
def __init__(
self,
config_entry: HegelConfigEntry,
client: HegelClient,
source_map: dict[int, str],
unique_id: str,
) -> None:
"""Initialize the Hegel media player entity."""
self._entry = config_entry
self._client = client
self._source_map = source_map
# Set unique_id from config entry
self._attr_unique_id = unique_id
# Set device info
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=config_entry.title,
manufacturer="Hegel",
model=config_entry.data[CONF_MODEL],
)
# State will be populated by async_update on first connection
self._state: dict[str, Any] = {}
# Background tasks
self._connected_watcher_task: asyncio.Task[None] | None = None
self._push_task: asyncio.Task[None] | None = None
self._push_handler: Callable[[str], None] | None = None
async def async_added_to_hass(self) -> None:
"""Handle entity added to Home Assistant."""
await super().async_added_to_hass()
_LOGGER.debug("Hegel media player added to hass: %s", self.entity_id)
# Register push handler for real-time updates from the amplifier
# The client expects a synchronous callable; schedule a coroutine safely
def push_handler(msg: str) -> None:
self._push_task = self.hass.async_create_task(self._async_handle_push(msg))
self._push_handler = push_handler
self._client.add_push_callback(push_handler)
# Register cleanup for push handler using async_on_remove
def cleanup_push_handler() -> None:
if self._push_handler:
self._client.remove_push_callback(self._push_handler)
_LOGGER.debug("Push callback removed")
self._push_handler = None
self.async_on_remove(cleanup_push_handler)
# Perform initial state fetch if already connected
# The watcher handles reconnections, but we need to fetch state on first setup
if self._client.is_connected():
_LOGGER.debug("Client already connected, performing initial state fetch")
await self.async_update()
# Start a watcher task
# Use config_entry.async_create_background_task for automatic cleanup on unload
self._connected_watcher_task = self._entry.async_create_background_task(
self.hass,
self._connected_watcher(),
name=f"hegel_{self.entity_id}_connected_watcher",
)
# Note: No need for async_on_remove - entry.async_create_background_task
# automatically cancels the task when the config entry is unloaded
# Schedule the heartbeat every 2 minutes while the reset timeout is 3 minutes
self.async_on_remove(
async_track_time_interval(
self.hass,
self._send_heartbeat,
timedelta(minutes=HEARTBEAT_TIMEOUT_MINUTES - 1),
)
)
# Send the first heartbeat immediately
self.hass.async_create_task(self._send_heartbeat())
async def _send_heartbeat(self, now=None) -> None:
if not self.available:
return
try:
await self._client.send(
f"-r.{HEARTBEAT_TIMEOUT_MINUTES}", expect_reply=False
)
except (HegelConnectionError, TimeoutError, OSError) as err:
_LOGGER.debug("Heartbeat failed: %s", err)
async def _async_handle_push(self, msg: str) -> None:
"""Handle incoming push message from client (runs in event loop)."""
try:
update = parse_reply_message(msg)
if update.has_changes():
apply_state_changes(self._state, update, logger=_LOGGER, source="push")
# notify HA
self.async_write_ha_state()
except ValueError, KeyError, AttributeError:
_LOGGER.exception("Failed to handle push message")
async def _connected_watcher(self) -> None:
"""Watch the client's connection events and update state accordingly."""
conn_event = self._client.connected_event
disconn_event = self._client.disconnected_event
_LOGGER.debug("Connected watcher started")
try:
while True:
# Wait for connection
_LOGGER.debug("Watcher: waiting for connection")
await conn_event.wait()
_LOGGER.debug("Watcher: connected, refreshing state")
# Immediately notify HA that we're available again
self.async_write_ha_state()
# Schedule a state refresh through HA
self.async_schedule_update_ha_state(force_refresh=True)
# Wait for disconnection using event (no polling!)
_LOGGER.debug("Watcher: waiting for disconnection")
await disconn_event.wait()
_LOGGER.debug("Watcher: disconnected")
# Notify HA that we're unavailable
self.async_write_ha_state()
except asyncio.CancelledError:
_LOGGER.debug("Connected watcher cancelled")
except (HegelConnectionError, OSError) as err:
_LOGGER.warning("Connected watcher failed: %s", err)
async def async_will_remove_from_hass(self) -> None:
"""Handle entity removal from Home Assistant.
Note: Push callback cleanup is handled by async_on_remove.
_connected_watcher_task cleanup is handled automatically by
entry.async_create_background_task when the config entry is unloaded.
"""
await super().async_will_remove_from_hass()
# Cancel push task if running (short-lived task, defensive cleanup)
if self._push_task and not self._push_task.done():
self._push_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._push_task
async def async_update(self) -> None:
"""Query the amplifier for the main values and update state dict."""
for cmd in (
COMMANDS["power_query"],
COMMANDS["volume_query"],
COMMANDS["mute_query"],
COMMANDS["input_query"],
):
try:
update = await self._client.send(cmd, expect_reply=True, timeout=3.0)
if update and update.has_changes():
apply_state_changes(
self._state, update, logger=_LOGGER, source="update"
)
except (HegelConnectionError, TimeoutError, OSError) as err:
_LOGGER.debug("Refresh command %s failed: %s", cmd, err)
# update entity state
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return True if the client is connected."""
return self._client.is_connected()
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the media player."""
power = self._state.get("power")
if power is None:
return None
return MediaPlayerState.ON if power else MediaPlayerState.OFF
@property
def volume_level(self) -> float | None:
"""Return the volume level."""
volume = self._state.get("volume")
if volume is None:
return None
return float(volume)
@property
def is_volume_muted(self) -> bool | None:
"""Return whether volume is muted."""
return bool(self._state.get("mute", False))
@property
def source(self) -> str | None:
"""Return the current input source."""
idx = self._state.get("input")
return self._source_map.get(idx, f"Input {idx}") if idx else None
@property
def source_list(self) -> list[str] | None:
"""Return the list of available input sources."""
return [self._source_map[k] for k in sorted(self._source_map.keys())] or None
async def async_turn_on(self) -> None:
"""Turn on the media player."""
try:
await self._client.send(COMMANDS["power_on"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to turn on: {err}") from err
async def async_turn_off(self) -> None:
"""Turn off the media player."""
try:
await self._client.send(COMMANDS["power_off"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to turn off: {err}") from err
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
vol = max(0.0, min(volume, 1.0))
amp_vol = int(round(vol * 100))
try:
await self._client.send(COMMANDS["volume_set"](amp_vol), expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to set volume: {err}") from err
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute the volume."""
try:
await self._client.send(
COMMANDS["mute_on" if mute else "mute_off"], expect_reply=False
)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to set mute: {err}") from err
async def async_volume_up(self) -> None:
"""Increase volume."""
try:
await self._client.send(COMMANDS["volume_up"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to increase volume: {err}") from err
async def async_volume_down(self) -> None:
"""Decrease volume."""
try:
await self._client.send(COMMANDS["volume_down"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to decrease volume: {err}") from err
async def async_select_source(self, source: str) -> None:
"""Select input source."""
inv = {v: k for k, v in self._source_map.items()}
idx = inv.get(source)
if idx is None:
raise ServiceValidationError(f"Unknown source: {source}")
try:
await self._client.send(COMMANDS["input_set"](idx), expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
f"Failed to select source {source}: {err}"
) from err

View File

@@ -0,0 +1,95 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: done
comment: |
Entities subscribe to push events from hegel-ip-client library.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not provide an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
Device uses local IP control without authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Device type integration.
entity-category:
status: exempt
comment: |
Single media_player entity, no categories needed.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
Single main entity, should be enabled by default.
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No repair issues needed for this integration.
stale-devices:
status: exempt
comment: |
Device type integration.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
Uses raw TCP connection, not HTTP.
strict-typing: todo

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_host_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"discovery_confirm": {
"data": {
"model": "Model"
},
"data_description": {
"model": "Select your Hegel amplifier model for proper input mapping"
},
"description": "Discovered Hegel amplifier **{name}** at `{host}`. Confirm the model to complete setup.",
"title": "Confirm Hegel amplifier"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"model": "Model"
},
"data_description": {
"host": "Hostname or IP address of your Hegel amplifier",
"model": "Select your Hegel amplifier model for proper input mapping"
}
}
}
}
}

View File

@@ -39,6 +39,15 @@
"platform_schema_validator_err": {
"message": "Unknown error when validating config for {domain} from integration {p_name} - {error}."
},
"service_config_entry_not_found": {
"message": "Integration {domain} config entry with ID {entry_id} was not found."
},
"service_config_entry_not_loaded": {
"message": "Config entry {entry_title} for integration {domain} is not loaded."
},
"service_config_entry_wrong_domain": {
"message": "Config entry {entry_title} does not belong to integration {domain}."
},
"service_does_not_support_response": {
"message": "An action which does not return responses can't be called with {return_response}."
},

View File

@@ -42,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .helpers import smoke_detector_channel_data_exists
ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode"
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
@@ -125,6 +126,8 @@ async def async_setup_entry(
entities.append(HomematicipPresenceDetector(hap, device))
if isinstance(device, SmokeDetector):
entities.append(HomematicipSmokeDetector(hap, device))
if smoke_detector_channel_data_exists(device, "chamberDegraded"):
entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device))
if isinstance(device, WaterSensor):
entities.append(HomematicipWaterDetector(hap, device))
if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
@@ -322,6 +325,23 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity):
return False
class HomematicipSmokeDetectorChamberDegraded(
HomematicipGenericEntity, BinarySensorEntity
):
"""Representation of the HomematicIP smoke detector chamber health."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize smoke detector chamber health sensor."""
super().__init__(hap, device, post="Chamber Degraded")
@property
def is_on(self) -> bool:
"""Return true if smoke chamber is degraded."""
return self._device.chamberDegraded
class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP water detector."""

View File

@@ -59,3 +59,16 @@ def get_channels_from_device(device: Device, channel_type: FunctionalChannelType
for ch in device.functionalChannels
if ch.functionalChannelType == channel_type
]
def smoke_detector_channel_data_exists(device: Device, field: str) -> bool:
"""Check if a smoke detector's channel payload contains a specific field.
The library always initializes device attributes with defaults, so hasattr
cannot distinguish between actual API data and defaults. This checks the
raw channel payload to determine if the field was actually sent by the API.
"""
channels = get_channels_from_device(
device, FunctionalChannelType.SMOKE_DETECTOR_CHANNEL
)
return bool(channels and field in getattr(channels[0], "_rawJSONData", {}))

View File

@@ -1,11 +1,11 @@
{
"domain": "homematicip_cloud",
"name": "HomematicIP Cloud",
"codeowners": ["@hahn-th"],
"codeowners": ["@hahn-th", "@lackas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.5.0"]
"requirements": ["homematicip==2.6.0"]
}

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from homematicip.base.enums import FunctionalChannelType, ValveState
@@ -27,6 +29,7 @@ from homematicip.device import (
PassageDetector,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
SmokeDetector,
SwitchMeasuring,
TemperatureDifferenceSensor2,
TemperatureHumiditySensorDisplay,
@@ -43,6 +46,7 @@ from homematicip.device import (
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
@@ -65,7 +69,70 @@ from homeassistant.helpers.typing import StateType
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .helpers import get_channels_from_device
from .helpers import get_channels_from_device, smoke_detector_channel_data_exists
@dataclass(frozen=True, kw_only=True)
class HmipSmokeDetectorSensorDescription(SensorEntityDescription):
"""Describes HmIP smoke detector sensor entity."""
value_fn: Callable[[SmokeDetector], StateType | datetime]
channel_field: str # Field name in the raw channel payload
SMOKE_DETECTOR_SENSORS: tuple[HmipSmokeDetectorSensorDescription, ...] = (
HmipSmokeDetectorSensorDescription(
key="dirt_level",
translation_key="smoke_detector_dirt_level",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
channel_field="dirtLevel",
value_fn=lambda d: (
round(d.dirtLevel * 100, 1) if d.dirtLevel is not None else None
),
),
HmipSmokeDetectorSensorDescription(
key="smoke_alarm_counter",
translation_key="smoke_detector_alarm_counter",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
channel_field="smokeAlarmCounter",
value_fn=lambda d: d.smokeAlarmCounter,
),
HmipSmokeDetectorSensorDescription(
key="smoke_test_counter",
translation_key="smoke_detector_test_counter",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
channel_field="smokeTestCounter",
value_fn=lambda d: d.smokeTestCounter,
),
HmipSmokeDetectorSensorDescription(
key="last_smoke_alarm",
translation_key="smoke_detector_last_alarm",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=False,
channel_field="lastSmokeAlarmTimestamp",
value_fn=lambda d: (
datetime.fromtimestamp(d.lastSmokeAlarmTimestamp / 1000, tz=UTC)
if d.lastSmokeAlarmTimestamp
else None
),
),
HmipSmokeDetectorSensorDescription(
key="last_smoke_test",
translation_key="smoke_detector_last_test",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=False,
channel_field="lastSmokeTestTimestamp",
value_fn=lambda d: (
datetime.fromtimestamp(d.lastSmokeTestTimestamp / 1000, tz=UTC)
if d.lastSmokeTestTimestamp
else None
),
),
)
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle"
@@ -289,6 +356,15 @@ async def async_setup_entry(
and getattr(channel, "valvePosition", None) is not None
)
# Handle smoke detector extended sensors (e.g., HmIP-SWSD-2)
entities.extend(
HmipSmokeDetectorSensor(hap, device, description)
for device in hap.home.devices
if isinstance(device, SmokeDetector)
for description in SMOKE_DETECTOR_SENSORS
if smoke_detector_channel_data_exists(device, description.channel_field)
)
async_add_entities(entities)
@@ -936,6 +1012,33 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt
return state_attr
class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
"""Sensor for HomematicIP smoke detector extended properties."""
entity_description: HmipSmokeDetectorSensorDescription
def __init__(
self,
hap: HomematicipHAP,
device: SmokeDetector,
description: HmipSmokeDetectorSensorDescription,
) -> None:
"""Initialize the smoke detector sensor."""
super().__init__(hap, device, post=description.key)
self.entity_description = description
self._sensor_unique_id = f"{device.id}_{description.key}"
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._sensor_unique_id
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(self._device)
def _get_wind_direction(wind_direction_degree: float) -> str:
"""Convert wind direction degree to named direction."""
if 11.25 <= wind_direction_degree < 33.75:

View File

@@ -29,6 +29,21 @@
},
"entity": {
"sensor": {
"smoke_detector_alarm_counter": {
"name": "Alarm counter"
},
"smoke_detector_dirt_level": {
"name": "Dirt level"
},
"smoke_detector_last_alarm": {
"name": "Last alarm"
},
"smoke_detector_last_test": {
"name": "Last test"
},
"smoke_detector_test_counter": {
"name": "Test counter"
},
"tilt_state": {
"state": {
"neutral": "Neutral",

View File

@@ -17,6 +17,7 @@ from homematicip.device import (
PlugableSwitch,
PrintedCircuitBoardSwitch2,
PrintedCircuitBoardSwitchBattery,
StatusBoard8,
SwitchMeasuring,
WiredInput32,
WiredInputSwitch6,
@@ -57,6 +58,7 @@ async def async_setup_entry(
WiredSwitch4,
WiredSwitch8,
OpenCollector8Module,
StatusBoard8,
BrandSwitch2,
PrintedCircuitBoardSwitch2,
HeatingSwitch2,

View File

@@ -0,0 +1,46 @@
"""The Homevolt integration."""
from __future__ import annotations
from homevolt import Homevolt
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Set up Homevolt from a config entry."""
host: str = entry.data[CONF_HOST]
password: str | None = entry.data.get(CONF_PASSWORD)
websession = async_get_clientsession(hass)
client = Homevolt(host, password, websession=websession)
coordinator = HomevoltDataUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.client.close_connection()
return unload_ok

View File

@@ -0,0 +1,119 @@
"""Config flow for the Homevolt integration."""
from __future__ import annotations
import logging
from typing import Any
from homevolt import Homevolt, HomevoltAuthenticationError, HomevoltConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
STEP_CREDENTIALS_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Homevolt."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
async def check_status(self, client: Homevolt) -> dict[str, str]:
"""Check connection status and return errors if any."""
errors: dict[str, str] = {}
try:
await client.update_info()
except HomevoltAuthenticationError:
errors["base"] = "invalid_auth"
except HomevoltConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Error occurred while connecting to the Homevolt battery")
errors["base"] = "unknown"
return errors
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:
host = user_input[CONF_HOST]
password = None
websession = async_get_clientsession(self.hass)
client = Homevolt(host, password, websession=websession)
errors = await self.check_status(client)
if errors.get("base") == "invalid_auth":
self._host = host
return await self.async_step_credentials()
if not errors:
device_id = client.unique_id
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt",
data={
CONF_HOST: host,
CONF_PASSWORD: None,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_credentials(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the credentials step."""
errors: dict[str, str] = {}
assert self._host is not None
if user_input is not None:
password = user_input[CONF_PASSWORD]
websession = async_get_clientsession(self.hass)
client = Homevolt(self._host, password, websession=websession)
errors = await self.check_status(client)
if not errors:
device_id = client.unique_id
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt",
data={
CONF_HOST: self._host,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="credentials",
data_schema=STEP_CREDENTIALS_DATA_SCHEMA,
errors=errors,
description_placeholders={"host": self._host},
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Homevolt integration."""
from __future__ import annotations
from datetime import timedelta
DOMAIN = "homevolt"
MANUFACTURER = "Homevolt"
SCAN_INTERVAL = timedelta(seconds=15)

View File

@@ -0,0 +1,56 @@
"""Data update coordinator for Homevolt integration."""
from __future__ import annotations
import logging
from homevolt import (
Homevolt,
HomevoltAuthenticationError,
HomevoltConnectionError,
HomevoltError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
type HomevoltConfigEntry = ConfigEntry[HomevoltDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class HomevoltDataUpdateCoordinator(DataUpdateCoordinator[Homevolt]):
"""Class to manage fetching Homevolt data."""
config_entry: HomevoltConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: HomevoltConfigEntry,
client: Homevolt,
) -> None:
"""Initialize the Homevolt coordinator."""
self.client = client
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
async def _async_update_data(self) -> Homevolt:
"""Fetch data from the Homevolt API."""
try:
await self.client.update_info()
except HomevoltAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except (HomevoltConnectionError, HomevoltError) as err:
raise UpdateFailed(f"Error communicating with device: {err}") from err
return self.client

View File

@@ -0,0 +1,64 @@
"""Shared entity helpers for Homevolt."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltDataUpdateCoordinator
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
"""Base Homevolt entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
) -> None:
"""Initialize the Homevolt entity."""
super().__init__(coordinator)
device_id = coordinator.data.unique_id
device_metadata = coordinator.data.device_metadata.get(device_identifier)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Homevolt calls to handle exceptions."""
async def handler(
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except HomevoltAuthenticationError as error:
raise ConfigEntryAuthFailed("Authentication failed") from error
except HomevoltConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -0,0 +1,11 @@
{
"domain": "homevolt",
"name": "Homevolt",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["homevolt==0.4.3"]
}

View File

@@ -0,0 +1,160 @@
"""Support for Homevolt number entities."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
@dataclass(frozen=True, kw_only=True)
class HomevoltNumberEntityDescription(NumberEntityDescription):
"""Describes a Homevolt number entity."""
available_modes: list[int] | None = None # None means available in all modes
def get_value(self, coordinator: HomevoltDataUpdateCoordinator) -> float | None:
"""Get the value from the coordinator based on the key."""
return coordinator.client.schedule.get(self.key)
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
HomevoltNumberEntityDescription(
key="setpoint",
translation_key="setpoint",
device_class=NumberDeviceClass.POWER,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfPower.WATT,
native_min_value=0,
native_max_value=7000,
native_step=1,
available_modes=[1, 2, 7, 8], # Inverter/solar charge/discharge modes
),
HomevoltNumberEntityDescription(
key="max_charge",
translation_key="max_charge",
device_class=NumberDeviceClass.POWER,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfPower.WATT,
native_min_value=0,
native_max_value=7000,
native_step=1,
),
HomevoltNumberEntityDescription(
key="max_discharge",
translation_key="max_discharge",
device_class=NumberDeviceClass.POWER,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfPower.WATT,
native_min_value=0,
native_max_value=7000,
native_step=1,
),
HomevoltNumberEntityDescription(
key="min_soc",
translation_key="min_soc",
device_class=NumberDeviceClass.BATTERY,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=PERCENTAGE,
native_min_value=0,
native_max_value=100,
native_step=1,
),
HomevoltNumberEntityDescription(
key="max_soc",
translation_key="max_soc",
device_class=NumberDeviceClass.BATTERY,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=PERCENTAGE,
native_min_value=0,
native_max_value=100,
native_step=1,
),
HomevoltNumberEntityDescription(
key="grid_import_limit",
translation_key="grid_import_limit",
device_class=NumberDeviceClass.POWER,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfPower.WATT,
native_min_value=0,
native_max_value=7000,
native_step=1,
available_modes=[3, 5], # Grid charge modes
),
HomevoltNumberEntityDescription(
key="grid_export_limit",
translation_key="grid_export_limit",
device_class=NumberDeviceClass.POWER,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfPower.WATT,
native_min_value=0,
native_max_value=7000,
native_step=1,
available_modes=[4, 5], # Grid discharge modes
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt number entities."""
coordinator = entry.runtime_data
async_add_entities(
HomevoltNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS
)
class HomevoltNumber(HomevoltEntity, NumberEntity):
"""Representation of a Homevolt number entity."""
entity_description: HomevoltNumberEntityDescription
def __init__(
self,
coordinator: HomevoltDataUpdateCoordinator,
description: HomevoltNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def available(self) -> bool:
"""Return if entity is available based on current mode."""
if not super().available:
return False
if self.entity_description.available_modes is not None:
current_mode = self.coordinator.client.schedule_mode
if current_mode not in self.entity_description.available_modes:
return False
return True
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.get_value(self.coordinator)
@homevolt_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
kwargs = {self.entity_description.key: int(value)}
await self.coordinator.client.set_battery_mode(**kwargs)
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Local_polling without events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,51 @@
"""Support for Homevolt select entities."""
from __future__ import annotations
from homevolt.const import SCHEDULE_TYPE
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt select entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltModeSelect(coordinator)])
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
"""Select entity for battery operational mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "battery_mode"
_attr_options = list(SCHEDULE_TYPE.values())
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def current_option(self) -> str | None:
"""Return the current selected mode."""
mode_int = self.coordinator.client.schedule_mode
return SCHEDULE_TYPE.get(mode_int, "idle")
@homevolt_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
await self.coordinator.client.set_battery_mode(mode=option)
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,365 @@
"""Support for Homevolt sensors."""
from __future__ import annotations
import logging
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PARALLEL_UPDATES = 0 # Coordinator-based updates
_LOGGER = logging.getLogger(__name__)
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="available_charging_energy",
translation_key="available_charging_energy",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="available_charging_power",
translation_key="available_charging_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
SensorEntityDescription(
key="available_discharge_energy",
translation_key="available_discharge_energy",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="available_discharge_power",
translation_key="available_discharge_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
SensorEntityDescription(
key="rssi",
translation_key="rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="average_rssi",
translation_key="average_rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="charge_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement="cycles",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="energy_exported",
translation_key="energy_exported",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="energy_imported",
translation_key="energy_imported",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="exported_energy",
translation_key="exported_energy",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="frequency",
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="imported_energy",
translation_key="imported_energy",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key="l1_current",
translation_key="l1_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
SensorEntityDescription(
key="l1_l2_voltage",
translation_key="l1_l2_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
SensorEntityDescription(
key="l1_power",
translation_key="l1_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="l1_voltage",
translation_key="l1_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="l2_current",
translation_key="l2_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
SensorEntityDescription(
key="l2_l3_voltage",
translation_key="l2_l3_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
SensorEntityDescription(
key="l2_power",
translation_key="l2_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="l2_voltage",
translation_key="l2_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="l3_current",
translation_key="l3_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
SensorEntityDescription(
key="l3_l1_voltage",
translation_key="l3_l1_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
SensorEntityDescription(
key="l3_power",
translation_key="l3_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="l3_voltage",
translation_key="l3_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="schedule_id",
translation_key="schedule_id",
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="schedule_max_discharge",
translation_key="schedule_max_discharge",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="schedule_max_power",
translation_key="schedule_max_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="schedule_power_setpoint",
translation_key="schedule_power_setpoint",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="schedule_type",
translation_key="schedule_type",
device_class=SensorDeviceClass.ENUM,
options=[
"idle",
"inverter_charge",
"inverter_discharge",
"grid_charge",
"grid_discharge",
"grid_charge_discharge",
"frequency_reserve",
"solar_charge",
"solar_charge_discharge",
"full_solar_export",
],
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="state_of_charge",
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key="system_temperature",
translation_key="system_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="tmax",
translation_key="tmax",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
key="tmin",
translation_key="tmin",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homevolt sensor."""
coordinator = entry.runtime_data
entities: list[HomevoltSensor] = []
sensors_by_key = {sensor.key: sensor for sensor in SENSORS}
for sensor_key, sensor in coordinator.data.sensors.items():
if (description := sensors_by_key.get(sensor.type)) is None:
_LOGGER.warning("Unsupported sensor '%s' found during setup", sensor)
continue
entities.append(
HomevoltSensor(
description,
coordinator,
sensor_key,
)
)
async_add_entities(entities)
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
"""Representation of a Homevolt sensor."""
entity_description: SensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
description: SensorEntityDescription,
coordinator: HomevoltDataUpdateCoordinator,
sensor_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
unique_id = coordinator.data.unique_id
self._attr_unique_id = f"{unique_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._sensor_key in self.coordinator.data.sensors
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.data.sensors[self._sensor_key].value

View File

@@ -0,0 +1,200 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"credentials": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The local password configured for your Homevolt battery."
},
"description": "This device requires a password to connect. Please enter the password for {host}."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address or hostname of your Homevolt battery on your local network."
},
"description": "Connect Home Assistant to your Homevolt battery over the local network."
}
}
},
"entity": {
"number": {
"grid_export_limit": {
"name": "Grid export limit"
},
"grid_import_limit": {
"name": "Grid import limit"
},
"max_charge": {
"name": "Max charge power"
},
"max_discharge": {
"name": "Max discharge power"
},
"max_soc": {
"name": "Maximum state of charge"
},
"min_soc": {
"name": "Minimum state of charge"
},
"setpoint": {
"name": "Power setpoint"
}
},
"select": {
"battery_mode": {
"name": "Battery mode",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"
},
"available_charging_power": {
"name": "Available charging power"
},
"available_discharge_energy": {
"name": "Available discharge energy"
},
"available_discharge_power": {
"name": "Available discharge power"
},
"average_rssi": {
"name": "Average RSSI"
},
"battery_state_of_charge": {
"name": "Battery state of charge"
},
"charge_cycles": {
"unit_of_measurement": "cycles"
},
"energy_exported": {
"name": "Energy exported"
},
"energy_imported": {
"name": "Energy imported"
},
"exported_energy": {
"name": "Exported energy"
},
"imported_energy": {
"name": "Imported energy"
},
"l1_current": {
"name": "L1 current"
},
"l1_l2_voltage": {
"name": "L1-L2 voltage"
},
"l1_power": {
"name": "L1 power"
},
"l1_voltage": {
"name": "L1 voltage"
},
"l2_current": {
"name": "L2 current"
},
"l2_l3_voltage": {
"name": "L2-L3 voltage"
},
"l2_power": {
"name": "L2 power"
},
"l2_voltage": {
"name": "L2 voltage"
},
"l3_current": {
"name": "L3 current"
},
"l3_l1_voltage": {
"name": "L3-L1 voltage"
},
"l3_power": {
"name": "L3 power"
},
"l3_voltage": {
"name": "L3 voltage"
},
"power": {
"name": "Power"
},
"rssi": {
"name": "RSSI"
},
"schedule_id": {
"name": "Schedule ID"
},
"schedule_max_discharge": {
"name": "Schedule max discharge"
},
"schedule_max_power": {
"name": "Schedule max power"
},
"schedule_power_setpoint": {
"name": "Schedule power setpoint"
},
"schedule_type": {
"name": "Schedule type",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
},
"system_temperature": {
"name": "System temperature"
},
"tmax": {
"name": "Maximum temperature"
},
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
},
"exceptions": {
"communication_error": {
"message": "Failed to communicate with Homevolt: {error}"
},
"unknown_error": {
"message": "An unknown error occurred: {error}"
}
}
}

View File

@@ -0,0 +1,55 @@
"""Support for Homevolt switch entities."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt switch entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
"""Switch entity for Homevolt local mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "local_mode"
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the switch entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def is_on(self) -> bool:
"""Return the local mode state."""
return self.coordinator.client.local_mode_enabled
@homevolt_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable local mode."""
await self.coordinator.client.enable_local_mode()
await self.coordinator.async_request_refresh()
@homevolt_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable local mode."""
await self.coordinator.client.disable_local_mode()
await self.coordinator.async_request_refresh()

View File

@@ -28,7 +28,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8)
SCAN_INTERVAL = timedelta(minutes=1)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
PING_INTERVAL = 60

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.1"]
"requirements": ["aioautomower==2.7.3"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "silver",
"requirements": ["aioimmich==0.11.1"]
"requirements": ["aioimmich==0.12.0"]
}

View File

@@ -6,9 +6,9 @@ from aioimmich.exceptions import ImmichError
import voluptuous as vol
from homeassistant.components.media_source import async_resolve_media
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import service
from homeassistant.helpers.selector import MediaSelector
from .const import DOMAIN
@@ -38,23 +38,11 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
service_call.data,
)
hass = service_call.hass
target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry(
service_call.data[CONF_CONFIG_ENTRY_ID]
target_entry: ImmichConfigEntry = service.async_get_config_entry(
hass, DOMAIN, service_call.data[CONF_CONFIG_ENTRY_ID]
)
source_media_id = service_call.data[CONF_FILE]["media_content_id"]
if not target_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
)
if target_entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
media = await async_resolve_media(hass, source_media_id, None)
if media.path is None:
raise ServiceValidationError(

View File

@@ -79,12 +79,6 @@
"album_not_found": {
"message": "Album with ID `{album_id}` not found ({error})."
},
"config_entry_not_found": {
"message": "Config entry not found."
},
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"only_local_media_supported": {
"message": "Only local media files are currently supported."
},

View File

@@ -0,0 +1,51 @@
"""The IntelliClima VMC integration."""
from pyintelliclima.api import IntelliClimaAPI
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import LOGGER
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
PLATFORMS = [Platform.FAN]
async def async_setup_entry(
hass: HomeAssistant, entry: IntelliClimaConfigEntry
) -> bool:
"""Set up IntelliClima VMC from a config entry."""
# Create API client
session = async_get_clientsession(hass)
api = IntelliClimaAPI(
session,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
# Create coordinator
coordinator = IntelliClimaCoordinator(hass, entry, api)
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
LOGGER.debug(
"Discovered %d IntelliClima VMC device(s)",
len(coordinator.data.ecocomfort2_devices),
)
# Store coordinator
entry.runtime_data = coordinator
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: IntelliClimaConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,71 @@
"""Config flow for IntelliClima integration."""
from typing import Any
from pyintelliclima import IntelliClimaAPI, IntelliClimaAPIError, IntelliClimaAuthError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class IntelliClimaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for IntelliClima VMC."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
# Validate credentials
session = async_get_clientsession(self.hass)
api = IntelliClimaAPI(
session,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
# Test authentication
await api.authenticate()
# Get devices to ensure we can communicate with API
devices = await api.get_all_device_status()
except IntelliClimaAuthError:
errors["base"] = "invalid_auth"
except IntelliClimaAPIError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if devices.num_devices == 0:
errors["base"] = "no_devices"
else:
return self.async_create_entry(
title=f"IntelliClima ({user_input[CONF_USERNAME]})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

View File

@@ -0,0 +1,11 @@
"""Constants for the IntelliClima integration."""
from datetime import timedelta
import logging
LOGGER = logging.getLogger(__package__)
DOMAIN = "intelliclima"
# Update interval
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)

View File

@@ -0,0 +1,45 @@
"""DataUpdateCoordinator for IntelliClima."""
from pyintelliclima import IntelliClimaAPI, IntelliClimaAPIError, IntelliClimaDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
type IntelliClimaConfigEntry = ConfigEntry[IntelliClimaCoordinator]
class IntelliClimaCoordinator(DataUpdateCoordinator[IntelliClimaDevices]):
"""Coordinator to manage fetching IntelliClima data."""
def __init__(
self, hass: HomeAssistant, entry: IntelliClimaConfigEntry, api: IntelliClimaAPI
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
config_entry=entry,
)
self.api = api
async def _async_setup(self) -> None:
"""Set up the coordinator - called once during first refresh."""
# Authenticate and get initial device list
try:
await self.api.authenticate()
except IntelliClimaAPIError as err:
raise UpdateFailed(f"Failed to set up IntelliClima: {err}") from err
async def _async_update_data(self) -> IntelliClimaDevices:
"""Fetch data from API."""
try:
# Poll status for all devices
return await self.api.get_all_device_status()
except IntelliClimaAPIError as err:
raise UpdateFailed(f"Failed to update data: {err}") from err

View File

@@ -0,0 +1,74 @@
"""Platform for shared base classes for sensors."""
from pyintelliclima.intelliclima_types import IntelliClimaC800, IntelliClimaECO
from homeassistant.const import ATTR_CONNECTIONS, ATTR_MODEL, ATTR_SW_VERSION
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
CONNECTION_NETWORK_MAC,
DeviceInfo,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import IntelliClimaCoordinator
class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
"""Define a generic class for IntelliClima entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO | IntelliClimaC800,
) -> None:
"""Class initializer."""
super().__init__(coordinator=coordinator)
self._attr_unique_id = device.id
# Make this HA "device" use the IntelliClima device name.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
manufacturer="Fantini Cosmi",
name=device.name,
serial_number=device.crono_sn,
)
self._device_id = device.id
self._device_sn = device.crono_sn
class IntelliClimaECOEntity(IntelliClimaEntity):
"""Specific entity for the ECOCOMFORT 2.0."""
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self._attr_device_info: DeviceInfo = self.device_info or DeviceInfo()
self._attr_device_info[ATTR_MODEL] = "ECOCOMFORT 2.0"
self._attr_device_info[ATTR_SW_VERSION] = device.fw
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_BLUETOOTH, device.mac),
(CONNECTION_NETWORK_MAC, device.macwifi),
}
@property
def _device_data(self) -> IntelliClimaECO:
return self.coordinator.data.ecocomfort2_devices[self._device_id]
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self._device_id in self.coordinator.data.ecocomfort2_devices
)

View File

@@ -0,0 +1,173 @@
"""Fan platform for IntelliClima VMC."""
import math
from typing import Any
from pyintelliclima.const import FanMode, FanSpeed
from pyintelliclima.intelliclima_types import IntelliClimaECO
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from homeassistant.util.scaling import int_states_in_range
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
from .entity import IntelliClimaECOEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: IntelliClimaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IntelliClima VMC fans."""
coordinator = entry.runtime_data
entities: list[IntelliClimaVMCFan] = [
IntelliClimaVMCFan(
coordinator=coordinator,
device=ecocomfort2,
)
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
]
async_add_entities(entities)
class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
"""Representation of an IntelliClima VMC fan."""
_attr_name = None
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = ["auto"]
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
@property
def is_on(self) -> bool:
"""Return true if fan is on."""
return bool(self._device_data.mode_set != FanMode.off)
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
device_data = self._device_data
if device_data.speed_set == FanSpeed.auto:
return None
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return int_states_in_range(self._speed_range)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
device_data = self._device_data
if device_data.mode_set == FanMode.off:
return None
if (
device_data.speed_set == FanSpeed.auto
and device_data.mode_set == FanMode.sensor
):
return "auto"
return None
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan.
Defaults back to 25% if percentage argument is 0 to prevent loop of turning off/on
infinitely.
"""
percentage = 25 if percentage == 0 else percentage
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self.coordinator.api.ecocomfort.turn_off(self._device_sn)
await self.coordinator.async_request_refresh()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage."""
await self.async_set_mode_speed(percentage=percentage)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
await self.async_set_mode_speed(fan_mode=preset_mode)
async def async_set_mode_speed(
self, fan_mode: str | None = None, percentage: int | None = None
) -> None:
"""Set mode and speed.
If percentage is None, it first defaults to the respective property.
If that is also None, then percentage defaults to 25 (sleep)
"""
percentage = self.percentage if percentage is None else percentage
percentage = 25 if percentage is None else percentage
if fan_mode == "auto":
# auto is a special case with special mode and speed setting
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
await self.coordinator.async_request_refresh()
return
if percentage == 0:
# Setting fan speed to zero turns off the fan
await self.async_turn_off()
return
# Determine the fan mode
if fan_mode is not None:
# Set to requested fan_mode
mode = fan_mode
elif not self.is_on:
# Default to alternate fan mode if not turned on
mode = FanMode.alternate
else:
# Maintain current mode
mode = self._device_data.mode_set
speed = str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
)
)
)
speed = FanSpeed.sleep if speed == FanSpeed.off else speed
await self.coordinator.api.ecocomfort.set_mode_speed(
self._device_sn, mode, speed
)
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,11 @@
{
"domain": "intelliclima",
"name": "IntelliClima",
"codeowners": ["@dvdinth"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/intelliclima",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyintelliclima==0.2.2"]
}

View File

@@ -0,0 +1,75 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
No configuration parameters, so nothing to document.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
Currently 92% average, with minimum module at 80% coverage.
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: |
Unclear if discovery is possible.
discovery:
status: todo
comment: |
Unclear if discovery is possible.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: done
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing:
status: todo
comment: |
External pyintelliclima module does not fully conform to PEP 561 yet.

View File

@@ -0,0 +1,26 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No IntelliClima devices found in your account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::email%]"
},
"data_description": {
"password": "Your IntelliClima app password",
"username": "Your IntelliClima app username"
},
"description": "Authenticate against IntelliClima cloud"
}
}
}
}

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
"requirements": ["intellifire4py==4.2.1"]
"requirements": ["intellifire4py==4.3.1"]
}

View File

@@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__)
TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
NO_TIMER_COMMAND_RESPONSE = "no_timer_command"
@dataclass
@@ -192,6 +193,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
class NoTimerCommandError(intent.IntentHandleError):
"""Error when a conversation command does not match any intent."""
def __init__(self, command: str) -> None:
"""Initialize error."""
super().__init__(
f"Intent not recognized: {command}",
NO_TIMER_COMMAND_RESPONSE,
)
class TimersNotSupportedError(intent.IntentHandleError):
"""Error when a timer intent is used from a device that isn't registered to handle timer events."""
@@ -836,6 +848,12 @@ class StartTimerIntentHandler(intent.IntentHandler):
# Fail early if this is not a delayed command
raise TimersNotSupportedError(intent_obj.device_id)
# Validate conversation command if provided
if conversation_command and not await self._validate_conversation_command(
intent_obj, conversation_command
):
raise NoTimerCommandError(conversation_command)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
@@ -865,6 +883,48 @@ class StartTimerIntentHandler(intent.IntentHandler):
return intent_obj.create_response()
async def _validate_conversation_command(
self, intent_obj: intent.Intent, conversation_command: str
) -> bool:
"""Validate that a conversation command can be executed."""
from homeassistant.components.conversation import ( # noqa: PLC0415
ConversationInput,
async_get_agent,
default_agent,
)
# Only validate if using the default agent
conversation_agent = async_get_agent(
intent_obj.hass, intent_obj.conversation_agent_id
)
if conversation_agent is None or not isinstance(
conversation_agent, default_agent.DefaultAgent
):
return True # Skip validation
test_input = ConversationInput(
text=conversation_command,
context=intent_obj.context,
conversation_id=None,
device_id=intent_obj.device_id,
satellite_id=intent_obj.satellite_id,
language=intent_obj.language,
agent_id=conversation_agent.entity_id,
)
# check for sentence trigger
if (
await conversation_agent.async_recognize_sentence_trigger(test_input)
) is not None:
return True
# check for intent
if (await conversation_agent.async_recognize_intent(test_input)) is not None:
return True
return False
class CancelTimerIntentHandler(intent.IntentHandler):
"""Intent handler for cancelling a timer."""

View File

@@ -30,7 +30,7 @@ from .entity import IOmeterEntity
class IOmeterEntityDescription(SensorEntityDescription):
"""Describes IOmeter sensor entity."""
value_fn: Callable[[IOmeterData], str | int | float]
value_fn: Callable[[IOmeterData], str | int | float | None]
SENSOR_TYPES: list[IOmeterEntityDescription] = [
@@ -73,7 +73,11 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: int(round(data.status.device.core.battery_level)),
value_fn=lambda data: (
int(round(data.status.device.core.battery_level))
if data.status.device.core.battery_level is not None
else None
),
),
IOmeterEntityDescription(
key="pin_status",

View File

@@ -21,6 +21,7 @@ from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
from .helpers import (
async_is_preview_feature_enabled,
async_listen,
async_subscribe_preview_feature,
async_update_preview_feature,
)
from .models import (
@@ -41,6 +42,7 @@ __all__ = [
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
"async_listen",
"async_subscribe_preview_feature",
"async_update_preview_feature",
]

View File

@@ -2,7 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import Event, HomeAssistant, callback
@@ -32,6 +33,43 @@ def async_is_preview_feature_enabled(
return (domain, preview_feature) in labs_data.data.preview_feature_status
@callback
def async_subscribe_preview_feature(
hass: HomeAssistant,
domain: str,
preview_feature: str,
listener: Callable[[EventLabsUpdatedData], Coroutine[Any, Any, None]],
) -> Callable[[], None]:
"""Listen for changes to a specific preview feature.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
listener: Coroutine function to invoke when the preview feature
is toggled. Receives the event data as argument. Runs eagerly.
Returns:
Callable to unsubscribe from the listener
"""
@callback
def _async_event_filter(event_data: EventLabsUpdatedData) -> bool:
"""Filter labs events for this integration's preview feature."""
return (
event_data["domain"] == domain
and event_data["preview_feature"] == preview_feature
)
async def _handler(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
await listener(event.data)
return hass.bus.async_listen(
EVENT_LABS_UPDATED, _handler, event_filter=_async_event_filter
)
@callback
def async_listen(
hass: HomeAssistant,
@@ -51,16 +89,10 @@ def async_listen(
Callable to unsubscribe from the listener
"""
@callback
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == domain
and event.data["preview_feature"] == preview_feature
):
listener()
async def _listener(_event_data: EventLabsUpdatedData) -> None:
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
return async_subscribe_preview_feature(hass, domain, preview_feature, _listener)
async def async_update_preview_feature(

View File

@@ -13,9 +13,10 @@ from homeassistant.core import HomeAssistant, callback
from .const import LABS_DATA
from .helpers import (
async_is_preview_feature_enabled,
async_listen,
async_subscribe_preview_feature,
async_update_preview_feature,
)
from .models import EventLabsUpdatedData
@callback
@@ -102,7 +103,6 @@ async def websocket_update_preview_feature(
connection.send_result(msg["id"])
@callback
@websocket_api.websocket_command(
{
vol.Required("type"): "labs/subscribe",
@@ -110,7 +110,8 @@ async def websocket_update_preview_feature(
vol.Required("preview_feature"): str,
}
)
def websocket_subscribe_feature(
@websocket_api.async_response
async def websocket_subscribe_feature(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -132,10 +133,13 @@ def websocket_subscribe_feature(
preview_feature = labs_data.preview_features[preview_feature_id]
@callback
def send_event() -> None:
async def send_event(event_data: EventLabsUpdatedData | None = None) -> None:
"""Send feature state to client."""
enabled = async_is_preview_feature_enabled(hass, domain, preview_feature_key)
enabled = (
event_data["enabled"]
if event_data is not None
else async_is_preview_feature_enabled(hass, domain, preview_feature_key)
)
connection.send_message(
websocket_api.event_message(
msg["id"],
@@ -143,9 +147,9 @@ def websocket_subscribe_feature(
)
)
connection.subscriptions[msg["id"]] = async_listen(
connection.subscriptions[msg["id"]] = async_subscribe_preview_feature(
hass, domain, preview_feature_key, send_event
)
connection.send_result(msg["id"])
send_event()
await send_event()

View File

@@ -0,0 +1,34 @@
"""Diagnostics support for Liebherr."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from .coordinator import LiebherrConfigEntry
TO_REDACT = {CONF_API_KEY}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LiebherrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"devices": {
device_id: {
"coordinator": {
"last_update_success": coordinator.last_update_success,
"update_interval": str(coordinator.update_interval),
"last_exception": str(coordinator.last_exception)
if coordinator.last_exception
else None,
},
"data": asdict(coordinator.data),
}
for device_id, coordinator in entry.runtime_data.items()
},
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Cloud API does not require updating entry data from network discovery.

View File

@@ -805,39 +805,6 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the color mode of the light."""
return self._attr_color_mode
@property
def _light_internal_color_mode(self) -> str:
"""Return the color mode of the light with backwards compatibility."""
if (color_mode := self.color_mode) is None:
# Backwards compatibility for color_mode added in 2021.4
# Warning added in 2024.3, break in 2025.3
if not self.__color_mode_reported and self.__should_report_light_issue():
self.__color_mode_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"%s (%s) does not report a color mode, this will stop working "
"in Home Assistant Core 2025.3, please %s"
),
self.entity_id,
type(self),
report_issue,
)
supported = self._light_internal_supported_color_modes
if ColorMode.HS in supported and self.hs_color is not None:
return ColorMode.HS
if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported and self.brightness is not None:
return ColorMode.BRIGHTNESS
if ColorMode.ONOFF in supported:
return ColorMode.ONOFF
return ColorMode.UNKNOWN
return color_mode
@cached_property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
@@ -985,8 +952,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def __validate_color_mode(
self,
color_mode: ColorMode | str | None,
supported_color_modes: set[ColorMode] | set[str],
color_mode: ColorMode | None,
supported_color_modes: set[ColorMode],
effect: str | None,
) -> None:
"""Validate the color mode."""
@@ -999,23 +966,10 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# color modes
if color_mode in supported_color_modes:
return
# Warning added in 2024.3, reject in 2025.3
if not self.__color_mode_reported and self.__should_report_light_issue():
self.__color_mode_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"%s (%s) set to unsupported color mode %s, expected one of %s, "
"this will stop working in Home Assistant Core 2025.3, "
"please %s"
),
self.entity_id,
type(self),
color_mode,
supported_color_modes,
report_issue,
)
return
raise HomeAssistantError(
f"{self.entity_id} ({type(self)}) set to unsupported color mode "
f"{color_mode}, expected one of {supported_color_modes}"
)
# When an effect is active, the color mode should indicate what adjustments are
# supported by the effect. To make this possible, we allow the light to set its
@@ -1028,49 +982,24 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if color_mode in effect_color_modes:
return
# Warning added in 2024.3, reject in 2025.3
if not self.__color_mode_reported and self.__should_report_light_issue():
self.__color_mode_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"%s (%s) set to unsupported color mode %s when rendering an effect,"
" expected one of %s, this will stop working in Home Assistant "
"Core 2025.3, please %s"
),
self.entity_id,
type(self),
color_mode,
effect_color_modes,
report_issue,
)
return
raise HomeAssistantError(
f"{self.entity_id} ({type(self)}) set to unsupported color mode "
f"{color_mode} when rendering an effect, expected one "
f"of {effect_color_modes}"
)
def __validate_supported_color_modes(
self,
supported_color_modes: set[ColorMode],
) -> None:
"""Validate the supported color modes."""
if self.__color_mode_reported:
return
try:
valid_supported_color_modes(supported_color_modes)
except vol.Error:
# Warning added in 2024.3, reject in 2025.3
if not self.__color_mode_reported and self.__should_report_light_issue():
self.__color_mode_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"%s (%s) sets invalid supported color modes %s, this will stop "
"working in Home Assistant Core 2025.3, please %s"
),
self.entity_id,
type(self),
supported_color_modes,
report_issue,
)
except vol.Error as err:
raise HomeAssistantError(
f"{self.entity_id} ({type(self)}) sets invalid supported color modes "
f"{supported_color_modes}"
) from err
@final
@property
@@ -1084,13 +1013,17 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
_is_on = self.is_on
color_mode = self._light_internal_color_mode if _is_on else None
color_mode = self.color_mode if _is_on else None
if _is_on and color_mode is None:
raise HomeAssistantError(
f"{self.entity_id} ({type(self)}) does not report a color mode"
)
effect: str | None
effect: str | None = None
if LightEntityFeature.EFFECT in supported_features:
data[ATTR_EFFECT] = effect = self.effect if _is_on else None
else:
effect = None
if _is_on:
effect = self.effect
data[ATTR_EFFECT] = effect
self.__validate_color_mode(color_mode, legacy_supported_color_modes, effect)

View File

@@ -1,165 +0,0 @@
# Dashboard Creation Guide
This guide provides best practices for building effective Home Assistant dashboards.
## Basic Structure of a Dashboard
A dashboard is a collection of views, and each view contains sections with cards. The basic structure looks like this:
```yaml
views:
- title: Living Room
path: living-room
icon: mdi:sofa
badges:
- type: entity
entity: sensor.living_room_temperature
- type: entity
entity: sensor.living_room_humidity
sections:
- type: grid
title: Lights
cards:
- type: tile
entity: light.living_room_ceiling
features:
- type: light-brightness
- type: tile
entity: light.floor_lamp
- type: tile
entity: light.reading_lamp
- type: grid
title: Climate
cards:
- type: thermostat
entity: climate.living_room
- type: tile
entity: sensor.living_room_temperature
- type: tile
entity: sensor.living_room_humidity
```
## Registry Listing Strategy
Use the list tools first to discover available data before building cards:
- `area_list`: list areas and filter with `area-id` and `floor`
- `device_list`: list devices and filter with `device-id`, `area`, and `floor`
- `entity_list`: list entities and filter with `entity-id`, `domain`, `area`, `floor`, `label`, `device`, and `device-class`
When needed, use `count`, `brief`, and `limit` flags to narrow output and then run a second call with the exact IDs you want to include in the dashboard.
## Task-Focused Dashboards
When creating a dashboard focused on a specific task that involves a few devices (e.g., "Home Office", "Coffee Station", "Media Center"), include a **Maintenance section** alongside the primary controls. This section should contain:
- Battery levels for wireless devices
- Signal strength indicators
- Firmware update status
- Device connectivity states
- Any diagnostic entities relevant to the devices
This approach keeps users informed about the health of the devices supporting their task without cluttering the main interface. When something stops working, the maintenance section provides immediate visibility into potential issues.
## Respect Entity Categories
Entities have categories that indicate their intended purpose:
- **No category (primary)**: Main controls and states meant for regular user interaction
- **Diagnostic**: Entities for maintenance and troubleshooting (e.g., signal strength, battery level, firmware version)
- **Config**: Configuration entities for device settings (e.g., sensitivity levels, LED brightness)
When building dashboards:
- Group primary entities together for the main user interface
- Place diagnostic entities in a separate "Maintenance" or "Diagnostics" section
- Config entities typically belong in a dedicated settings area, not the main dashboard
This separation keeps dashboards clean and prevents users from accidentally changing configuration settings.
## Tile Card Features for Enhanced Control
Tile cards support features that provide additional control directly on the card. Consider using tile card features for:
- **Primary controls**: Light brightness slider, cover position, fan speed
- **Frequently used actions**: Toggle switches, quick actions
Avoid adding features to:
- Diagnostic entities
- Configuration entities
- Entities where simple state display is sufficient
Tile card features make important controls more accessible and visually prominent.
```yaml
type: tile
entity: light.ceiling_lights
features:
- type: light-brightness
```
Available features: `cover-open-close`, `cover-position`, `cover-tilt`, `cover-tilt-position`, `light-brightness`, `light-color-temp`, `lock-commands`, `lock-open-door`, `media-player-playback`, `media-player-volume-slider`, `media-player-volume-buttons`, `fan-direction`, `fan-oscillate`, `fan-preset-modes`, `fan-speed`, `alarm-modes`, `climate-fan-modes`, `climate-swing-modes`, `climate-swing-horizontal-modes`, `climate-hvac-modes`, `climate-preset-modes`, `counter-actions`, `date-set`, `select-options`, `numeric-input`, `target-humidity`, `target-temperature`, `toggle`, `water-heater-operation-modes`, `humidifier-modes`, `humidifier-toggle`, `vacuum-commands`, `valve-open-close`, `valve-position`, `lawn-mower-commands`, `update-actions`, `trend-graph`, `area-controls`, `bar-gauge`,
## Specialized Cards for Specific Domains
### Climate Entities
Use the **thermostat card** for climate entities. It provides:
- Current and target temperature display
- HVAC mode selection
- Temperature adjustment controls
- A visual representation that users intuitively understand
```yaml
type: thermostat
entity: climate.heatpump
```
### Camera and Image Entities
Use **picture-entity cards** for camera and image entities:
- Hide the state (the image itself is the state)
- Hide the name unless the image context is ambiguous (most cameras and images are self-explanatory when viewed)
- Let the visual content speak for itself
```yaml
type: picture-entity
entity: camera.demo_camera
show_state: false
show_name: false
camera_view: auto
fit_mode: cover
```
### Graph Cards
Sometimes you want to show historical data for an entity. The choice of graph card depends on the type of entity:
#### Statistics Graph (for sensor entities)
Use **statistics-graph** cards when displaying sensor data over time:
- Automatically calculates and displays statistics (mean, min, max)
- Optimized for numerical sensor data
- Better performance for long time ranges
#### History Graph (for other entity types)
Use **history-graph** cards for:
- Climate entity history (showing temperature changes alongside HVAC states)
- Binary sensor timelines
- State-based entities where you want to see state changes over time
- Any non-sensor entity where historical data is valuable
The history graph shows actual state changes as they occurred, which is more appropriate for non-numerical entities.
## Using Badges for Global Information
Badges are ideal for displaying global data points that apply to an entire dashboard view. Good candidates include:
- Area temperature and humidity
- Security system status
- Weather conditions
- Presence/occupancy indicators
- General alerts or warnings
If the information is more specific to a subset of the dashboard, consider adding it to a section header instead of a badge. Badges work best for truly dashboard-wide context.
```yaml
type: entity
entity: sensor.temperature
```

View File

@@ -196,9 +196,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
websocket_api.async_register_command(
hass, websocket.websocket_lovelace_delete_config
)
websocket_api.async_register_command(
hass, websocket.websocket_lovelace_generate_dashboard
)
yaml_dashboards = config[DOMAIN].get(CONF_DASHBOARDS, {})

View File

@@ -1,379 +0,0 @@
"""LLM tools for generating Lovelace dashboards."""
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
llm,
)
from homeassistant.util.json import JsonObjectType
API_ID = "lovelace_dashboard_generation"
API_NAME = "Lovelace Dashboard Generation"
API_PROMPT = """Use the list tools to discover available areas, devices and entities.
Always reference real entity_ids from tool results when building dashboard cards.
Return dashboard data that includes a top-level `views` array."""
GENERATE_GUIDELINES = Path(__file__).parent / "GUIDE.md"
_AREA_LIST_PARAMETERS = vol.Schema(
{
vol.Optional("area_id"): str,
vol.Optional("area-id"): str,
vol.Optional("floor"): str,
vol.Optional("count", default=False): bool,
vol.Optional("brief", default=False): bool,
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
}
)
_DEVICE_LIST_PARAMETERS = vol.Schema(
{
vol.Optional("device_id"): str,
vol.Optional("device-id"): str,
vol.Optional("area"): str,
vol.Optional("floor"): str,
vol.Optional("count", default=False): bool,
vol.Optional("brief", default=False): bool,
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
}
)
_ENTITY_LIST_PARAMETERS = vol.Schema(
{
vol.Optional("entity_id"): str,
vol.Optional("entity-id"): str,
vol.Optional("domain"): str,
vol.Optional("area"): str,
vol.Optional("floor"): str,
vol.Optional("label"): str,
vol.Optional("device"): str,
vol.Optional("device_class"): str,
vol.Optional("device-class"): str,
vol.Optional("count", default=False): bool,
vol.Optional("brief", default=False): bool,
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
}
)
def _tool_str(data: dict[str, Any], *keys: str) -> str | None:
"""Extract a string value from alternate parameter names."""
for key in keys:
value = data.get(key)
if isinstance(value, str):
return value
return None
def _entity_device_class(
reg_entry: er.RegistryEntry | None, attributes: dict[str, Any]
) -> str:
"""Resolve device class with the same precedence as hab entity list."""
if reg_entry and reg_entry.original_device_class:
return reg_entry.original_device_class
if reg_entry and reg_entry.device_class:
return reg_entry.device_class
device_class = attributes.get("device_class")
if isinstance(device_class, str):
return device_class
return ""
def _apply_limit(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
"""Apply list limit the same way as hab list commands."""
if limit > 0 and len(items) > limit:
return items[:limit]
return items
async def build_generation_instructions(hass: HomeAssistant, prompt: str) -> str:
"""Build instructions used for Lovelace dashboard generation."""
guide = await hass.async_add_executor_job(GENERATE_GUIDELINES.read_text)
return (
"Generate a Home Assistant Lovelace dashboard configuration.\n"
"Return only valid JSON (no markdown and no explanation).\n"
"Return a complete dashboard object with a top-level `views` array.\n"
"Each view should include useful cards for the user request.\n"
"Use the list tools to discover real area, device and entity IDs.\n"
"Use real entity IDs discovered from available tools.\n"
"Prioritize readable, practical dashboards over decorative layouts.\n\n"
f"User request:\n{prompt.strip()}\n\n"
f"{guide}"
)
class AreaListTool(llm.Tool):
"""Tool mirroring `hab area list`."""
name = "area_list"
description = (
"List areas with hab-compatible filters: area-id, floor, count, brief, limit."
)
parameters = _AREA_LIST_PARAMETERS
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the tool."""
self._hass = hass
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""List areas with hab-compatible output fields."""
del hass, llm_context
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
area_id_filter = _tool_str(data, "area_id", "area-id")
floor_filter = _tool_str(data, "floor")
count = cast(bool, data["count"])
brief = cast(bool, data["brief"])
limit = cast(int, data["limit"])
area_registry = ar.async_get(self._hass)
result: list[dict[str, Any]] = []
for area in area_registry.areas.values():
if area_id_filter and area.id != area_id_filter:
continue
if floor_filter and area.floor_id != floor_filter:
continue
result.append(
{
"area_id": area.id,
"name": area.name,
"floor_id": area.floor_id,
"icon": area.icon,
"labels": sorted(area.labels),
}
)
if count:
return {"count": len(result)}
result = _apply_limit(result, limit)
if brief:
return {
"areas": [
{"area_id": area["area_id"], "name": area["name"]}
for area in result
]
}
return {"areas": result}
class DeviceListTool(llm.Tool):
"""Tool mirroring `hab device list`."""
name = "device_list"
description = (
"List devices with hab-compatible filters: device-id, area, floor, count,"
" brief, limit."
)
parameters = _DEVICE_LIST_PARAMETERS
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the tool."""
self._hass = hass
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""List devices with hab-compatible output fields."""
del hass, llm_context
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
device_id_filter = _tool_str(data, "device_id", "device-id")
area_filter = _tool_str(data, "area")
floor_filter = _tool_str(data, "floor")
count = cast(bool, data["count"])
brief = cast(bool, data["brief"])
limit = cast(int, data["limit"])
area_floor_map: dict[str, str] = {}
if floor_filter:
area_registry = ar.async_get(self._hass)
area_floor_map = {
area.id: area.floor_id or ""
for area in area_registry.areas.values()
if area.id
}
device_registry = dr.async_get(self._hass)
result: list[dict[str, Any]] = []
for device in device_registry.devices.values():
if device_id_filter and device.id != device_id_filter:
continue
if area_filter and device.area_id != area_filter:
continue
if floor_filter:
if not device.area_id:
continue
if area_floor_map.get(device.area_id) != floor_filter:
continue
result.append(
{
"id": device.id,
"name": device.name,
"manufacturer": device.manufacturer,
"model": device.model,
"area_id": device.area_id,
}
)
if count:
return {"count": len(result)}
result = _apply_limit(result, limit)
if brief:
return {
"devices": [{"id": item["id"], "name": item["name"]} for item in result]
}
return {"devices": result}
class EntityListTool(llm.Tool):
"""Tool mirroring `hab entity list`."""
name = "entity_list"
description = (
"List entities with hab-compatible filters: entity-id, domain, area, floor,"
" label, device, device-class, count, brief, limit."
)
parameters = _ENTITY_LIST_PARAMETERS
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the tool."""
self._hass = hass
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""List entities with hab-compatible output fields."""
del hass, llm_context
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
entity_id_filter = _tool_str(data, "entity_id", "entity-id")
domain_filter = _tool_str(data, "domain")
area_filter = _tool_str(data, "area")
floor_filter = _tool_str(data, "floor")
label_filter = _tool_str(data, "label")
device_filter = _tool_str(data, "device")
device_class_filter = _tool_str(data, "device_class", "device-class")
count = cast(bool, data["count"])
brief = cast(bool, data["brief"])
limit = cast(int, data["limit"])
area_floor_map: dict[str, str] = {}
if floor_filter:
area_registry = ar.async_get(self._hass)
area_floor_map = {
area.id: area.floor_id or ""
for area in area_registry.areas.values()
if area.id
}
entity_registry = er.async_get(self._hass)
result: list[dict[str, Any]] = []
for state in self._hass.states.async_all():
entity_id = state.entity_id
if entity_id_filter and entity_id != entity_id_filter:
continue
if domain_filter and state.domain != domain_filter:
continue
reg_entry = entity_registry.async_get(entity_id)
if device_filter:
if reg_entry is None or reg_entry.device_id != device_filter:
continue
if area_filter:
if reg_entry is None or reg_entry.area_id != area_filter:
continue
if floor_filter:
if reg_entry is None or not reg_entry.area_id:
continue
if area_floor_map.get(reg_entry.area_id) != floor_filter:
continue
if label_filter:
if reg_entry is None or label_filter not in reg_entry.labels:
continue
friendly_name = state.attributes.get("friendly_name")
if not isinstance(friendly_name, str):
friendly_name = ""
device_class = _entity_device_class(reg_entry, state.attributes)
if device_class_filter and device_class != device_class_filter:
continue
result.append(
{
"entity_id": entity_id,
"state": state.state,
"name": friendly_name,
"area_id": reg_entry.area_id if reg_entry else "",
"device_id": reg_entry.device_id if reg_entry else "",
"device_class": device_class,
"labels": sorted(reg_entry.labels) if reg_entry else [],
"disabled": reg_entry.disabled_by is not None
if reg_entry
else False,
}
)
if count:
return {"count": len(result)}
result = _apply_limit(result, limit)
if brief:
return {
"entities": [
{"entity_id": item["entity_id"], "name": item["name"]}
for item in result
]
}
return {"entities": result}
class LovelaceDashboardGenerationAPI(llm.API):
"""LLM API for Lovelace dashboard generation."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the API."""
super().__init__(hass=hass, id=API_ID, name=API_NAME)
async def async_get_api_instance(
self, llm_context: llm.LLMContext
) -> llm.APIInstance:
"""Return the API instance."""
return llm.APIInstance(
api=self,
api_prompt=API_PROMPT,
llm_context=llm_context,
tools=[
AreaListTool(self.hass),
DeviceListTool(self.hass),
EntityListTool(self.hass),
],
)

View File

@@ -8,12 +8,11 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import ai_task, websocket_api
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_fragment
from homeassistant.util.json import json_loads
from .const import (
CONF_RESOURCE_MODE,
@@ -23,7 +22,6 @@ from .const import (
ConfigNotFound,
)
from .dashboard import LovelaceConfig
from .llm import LovelaceDashboardGenerationAPI, build_generation_instructions
if TYPE_CHECKING:
from .resources import ResourceStorageCollection
@@ -186,93 +184,3 @@ async def websocket_lovelace_delete_config(
) -> None:
"""Delete Lovelace UI configuration."""
await config.async_delete()
def _coerce_generated_dashboard(data: Any) -> dict[str, Any]:
"""Coerce AI output into a dashboard config object."""
if isinstance(data, dict):
return data
if not isinstance(data, str):
raise HomeAssistantError("Generated dashboard must be a valid JSON object")
candidates = [data.strip()]
if "```" in data:
for block in data.split("```"):
candidate = block.strip()
if not candidate:
continue
if candidate.casefold().startswith("json"):
candidate = candidate[4:].strip()
candidates.append(candidate)
for candidate in candidates:
try:
parsed = json_loads(candidate)
except ValueError:
continue
if isinstance(parsed, dict):
return parsed
raise HomeAssistantError("Generated dashboard must be a valid JSON object")
def _validate_generated_dashboard(data: Any) -> dict[str, Any]:
"""Validate generated dashboard response."""
if not isinstance(data, dict):
raise HomeAssistantError("Generated dashboard must be an object")
views = data.get("views")
if not isinstance(views, list) or not views:
raise HomeAssistantError(
"Generated dashboard must include at least one view in `views`"
)
if not all(isinstance(view, dict) for view in views):
raise HomeAssistantError("Each dashboard view must be an object")
return data
@websocket_api.require_admin
@websocket_api.websocket_command(
{
"type": "lovelace/config/generate",
vol.Required("prompt"): cv.string,
}
)
@websocket_api.async_response
async def websocket_lovelace_generate_dashboard(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a Lovelace dashboard configuration from a prompt."""
if ai_task.DOMAIN not in hass.config.components:
connection.send_error(
msg["id"],
"error",
"AI Task integration is not available. Configure AI Task first.",
)
return
try:
result = await ai_task.async_generate_data(
hass,
task_name="lovelace_dashboard_generation",
instructions=await build_generation_instructions(hass, msg["prompt"]),
llm_api=LovelaceDashboardGenerationAPI(hass),
)
config = _validate_generated_dashboard(_coerce_generated_dashboard(result.data))
except HomeAssistantError as err:
connection.send_error(msg["id"], "error", str(err))
return
connection.send_result(
msg["id"],
{
"conversation_id": result.conversation_id,
"config": config,
},
)

View File

@@ -0,0 +1,20 @@
"""Diagnostics support for Lunatone integration."""
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import LunatoneConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LunatoneConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"info": entry.runtime_data.coordinator_info.data.model_dump(),
"devices": [
v.data.model_dump()
for v in entry.runtime_data.coordinator_devices.data.values()
],
}

View File

@@ -51,7 +51,7 @@ rules:
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: todo
comment: Discovery not yet supported

View File

@@ -2,16 +2,16 @@
from enum import StrEnum
from functools import partial
from typing import Any, cast
from typing import Any
from mastodon import Mastodon
from mastodon.Mastodon import MastodonAPIError, MediaAttachment
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import service
from .const import (
ATTR_CONTENT_WARNING,
@@ -53,30 +53,15 @@ SERVICE_POST_SCHEMA = vol.Schema(
)
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry:
"""Get the Mastodon config entry."""
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return cast(MastodonConfigEntry, entry)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Mastodon integration."""
async def async_post(call: ServiceCall) -> ServiceResponse:
"""Post a status."""
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
entry: MastodonConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
client = entry.runtime_data.client
status: str = call.data[ATTR_STATUS]

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