Compare commits

..

369 Commits

Author SHA1 Message Date
Franck Nijhof
ed69f81ec4 Add reconfiguration flow to Twente Milieu 2026-04-12 10:23:32 +00:00
Franck Nijhof
f521838bf1 Add reconfiguration flow to Tailwind (#168033) 2026-04-12 11:50:52 +02:00
Franck Nijhof
efb0162c6f Set parallel updates for Tailwind platforms (#168025) 2026-04-12 11:13:13 +02:00
Franck Nijhof
ba62b6cbda Handle connection errors in Peblar zeroconf confirm step (#167998) 2026-04-12 10:11:13 +02:00
Franck Nijhof
4e13731838 Extract entity template functions into an entity Jinja2 extension (#167992) 2026-04-12 10:00:53 +02:00
Franck Nijhof
4f255c23dd Translate coordinator exceptions for Twente Milieu (#168005) 2026-04-11 23:05:55 +02:00
Franck Nijhof
af69e9b5de Translate exceptions raised by Elgato (#168004) 2026-04-11 23:05:47 +02:00
Franck Nijhof
df734655f6 Remove unused service constants from Twente Milieu (#168000) 2026-04-11 22:27:04 +02:00
Franck Nijhof
4926ea9ef0 Set parallel updates to 0 for RDW platforms (#168003) 2026-04-11 22:26:36 +02:00
Franck Nijhof
322dc2adeb Add DHCP discovery for known Elgato devices (#168002) 2026-04-11 22:26:22 +02:00
Franck Nijhof
2e648aca8b Mark exception-translations rule as done for Peblar (#167997) 2026-04-11 21:56:24 +02:00
Franck Nijhof
dac2777729 Mark entity-translations rule as done for Twente Milieu (#168001) 2026-04-11 21:56:01 +02:00
Willem-Jan van Rootselaar
1e1e37637f Bump python-bsblan to version 5.1.4 (#167987) 2026-04-11 18:45:56 +02:00
Joakim Plate
d695250507 Fix gardena entity categories and percentage values (#167986) 2026-04-11 18:44:03 +02:00
Richard Kroegel
ab7b257785 Add eurotronic cometblue integration (#165626)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-11 16:35:04 +02:00
J. Nick Koston
3b1fa609f7 Bump aioesphomeapi to 44.13.3 (#167966) 2026-04-11 16:28:29 +02:00
Kevin Stillhammer
822fae227a Add base_coords for OptionsFlow and action call in waze_travel_time (#166642) 2026-04-11 16:28:02 +02:00
J. Nick Koston
2fa0bdb2dc Fix ESPHome cold/warm white color temperature read-back (#167972) 2026-04-11 16:24:50 +02:00
Andres Ruiz
8a43d1a12c Add remote start/stop button for supported Subaru vehicles (#167100)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-11 16:22:19 +02:00
Erwin Douna
483265a707 Portainer fix fetching swarm stacks (#167979) 2026-04-11 16:21:16 +02:00
Raphael Hehl
84f5cd8a12 Bump uiprotect to 10.2.6 (#167978)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-11 14:32:01 +01:00
J. Diego Rodríguez Royo
e23da7a5f0 Bump aiohomeconnect to 0.36.0 (#167973) 2026-04-11 13:55:29 +02:00
Florent Thoumie
fe1e12a298 Improve iaqualink reauthentication flow (#167931)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-11 13:06:49 +02:00
tronikos
938eacd777 Bump opower to 0.18.1 (#167967) 2026-04-11 13:04:25 +02:00
David Bonnes
ba7a959727 Remove unused constant from Evohome's const.py (#167969) 2026-04-11 12:23:33 +02:00
Fabian Munkes
966eadad69 Follow up to adding support for sound modes to Music Assistant (#167929) 2026-04-11 11:07:52 +02:00
Fabian Munkes
f34ed8f8ba Follow-up to player options: switch entities in Music Assistant (#167964) 2026-04-11 10:46:20 +02:00
Brett Adams
ac4b253a2f Add LoginRequired exception handling to Teslemetry coordinators (#167959) 2026-04-11 10:37:37 +02:00
Fabian Munkes
640fea89e0 Follow-up to player options: number entities in Music Assistant (#167963) 2026-04-11 10:33:27 +02:00
Fabian Munkes
fdf1b6536a Follow-up to player options: text entities in Music Assistant (#167962) 2026-04-11 10:27:43 +02:00
Raphael Hehl
974047664c Bump unifi-discovery to version 1.4.0 (#167958)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-10 22:26:15 -10:00
Franck Nijhof
03d6f5a756 Update cryptography to 46.0.7 (#167960) 2026-04-11 10:00:37 +02:00
epenet
9f1c396407 Unlink tomorrowio coordinator from config entry (#167901) 2026-04-11 09:49:54 +02:00
J. Nick Koston
054b8ad534 Bump aioesphomeapi to 44.13.2 (#167952) 2026-04-10 16:48:34 -10:00
J. Nick Koston
b93cdc64f3 Bump bleak-esphome to 3.7.3 (#167953) 2026-04-10 16:27:52 -10:00
Fabian Munkes
59248e5414 Bump music-assistant-client to 1.3.5 (#167947) 2026-04-11 01:07:18 +02:00
Michael
a5b830cc34 Don't create cpu temperature sensor when not supported in FRITZ!Box Tools (#167905) 2026-04-11 00:04:23 +02:00
James
299562d6ee Set integer display precision for Yardian duration sensors (#165896)
Co-authored-by: barneyonline <barneyonline@users.noreply.github.com>
2026-04-10 23:58:57 +02:00
Denis Shulyaka
47cc31067c Check if model exists in Anthropic config flow (#167844) 2026-04-11 00:06:17 +03:00
Martin Hjelmare
f050407bfa Fix tibber price sensor first state update (#167938)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-10 22:50:36 +02:00
Tomeamis
a202742fc6 Z-Wave.me: Make Light support the Transition feature (#167840) 2026-04-10 22:45:36 +02:00
Ronald van der Meer
63a0b5d2ff Bump python-duco-client to 0.3.0 (#167936) 2026-04-10 22:19:39 +02:00
Raman Gupta
53ed4b2c77 Refactor Vizio tests: shared fixtures, snapshot_platform, reduced parametrize (#167935)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:19:25 +02:00
Tomer
2f91c6b050 Promote victron_gx integration to silver quality scale (#167789)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:24:54 +02:00
Raj Laud
d17cb0e096 Fix Victron BLE storage errors caused by non-serializable value_fn callable in sensor entity description (#167819)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:22:55 +02:00
mettolen
c9ee533916 Update Liebherr to platinum (#167836) 2026-04-10 20:17:04 +02:00
Raman Gupta
e88022c2cc Add remote platform to Vizio integration (#165820)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:13:11 +02:00
Nick Haghiri
d633ac8120 Improve error logging for Backblaze B2 upload failures (#167721) 2026-04-10 20:12:24 +02:00
potelux
fb90237ae3 Proxy Jellyfin artwork through HA so thumbnails work over HTTPS (#167238)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:08:09 +02:00
Artur Pragacz
4658f4246d Allow frontend-handled issues to omit description in strings (#167928) 2026-04-10 19:59:05 +02:00
tronikos
99e4c87f5e Add reauthentication and reconfiguration flows in Google Weather to reach platinum (#166106) 2026-04-10 19:55:33 +02:00
Abílio Costa
7690d9570c Narrow log check on ring event test (#167927) 2026-04-10 18:26:31 +01:00
On Freund
00560abd9c Bump pyrisco to 0.6.8 (#167924) 2026-04-10 18:47:51 +02:00
Bram Kragten
b6d4fca477 Update frontend to 20260325.7 (#167922) 2026-04-10 18:46:06 +02:00
Nathan Spencer
44e51c1103 Bump pylitterbot to 2025.2.1 (#167921) 2026-04-10 18:21:45 +02:00
Erik Montnemery
3ad2c5e574 Fix config validation in trigger and condition tests (#167683) 2026-04-10 18:20:04 +02:00
Marcello
212c9b1a94 Bump fluss-api to 0.2.4 (#167680)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-04-10 18:03:49 +02:00
epenet
b670172867 Bump tuya-device-handlers to 0.0.17 (#167904) 2026-04-10 18:01:28 +02:00
David Bonnes
23bcde09b0 Add Buttons to natively reset the mode of Evohome entities (#167550) 2026-04-10 18:00:30 +02:00
Tom Matheussen
62717fd3f5 Add support for encrypted connection to Satel Integra (#167372) 2026-04-10 17:57:26 +02:00
Simone Chemelli
86b72501ad Add faulty/anomaly binary sensors to Comelit (#167201) 2026-04-10 17:51:49 +02:00
Stef Coene
59827967e6 Velbus reconfigure fix (#167471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:56 +02:00
Thomas D
fe5d45ed57 Fix light on action for qbus integration (#167917) 2026-04-10 17:24:00 +02:00
Florent Thoumie
cf87e9ab72 iaqualink: move custom update logic to DataUpdateCoordinator (#167816)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 17:15:11 +02:00
Alex Merkel
64907ad7e2 [LG Soundbar] Fix incorrect state for some models (#167094) 2026-04-10 17:10:18 +02:00
Noah Husby
9e111b2418 Bump aiorussound to 5.0.0 (#167914) 2026-04-10 17:07:07 +02:00
Joost Lekkerkerker
97d64ab37c Bump zinvolt to 0.4.3 (#167908) 2026-04-10 17:02:51 +02:00
Tomer
547830b450 Victron GX switch platform (#167859)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 17:00:39 +02:00
Andrew Brainwood
f2f605b425 Add Preset button support for Bond cover devices (#167881)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-04-10 17:00:25 +02:00
Thomas D
781b5e1c0e Bump qbusmqttapi to 1.4.3 (#167909) 2026-04-10 16:57:05 +02:00
panosmz
68a7cbb620 Bump oasatelematics to 0.4 (#167911) 2026-04-10 16:48:11 +02:00
epenet
a6a716571d Use runtime_data in tesla_wall_connector (#167893)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:38:04 +02:00
epenet
ba09a54a37 Use runtime_data in tradfri integration (#167896)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:19:02 +02:00
puddly
7125796aac Temporarily stop the Z2M app when installing firmwares (#163958)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 14:16:47 +02:00
Robert Resch
ce9875806d Use runtime_data in launch_library integration (#167887)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-04-10 13:47:37 +02:00
Pierre Hauweele
7cf422361b Make the scaffold script ask for the integration type (#167725) 2026-04-10 12:49:40 +02:00
Robert Resch
9a97f1e8d2 Use runtime_data in soma integration (#167890)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:49:39 +02:00
Robert Resch
777f78f74d Use runtime_data in litejet integration (#167888) 2026-04-10 12:35:31 +02:00
Joost Lekkerkerker
10c922b21f Support Chess.com accounts with no name (#167824) 2026-04-10 12:34:05 +02:00
epenet
aa293ba2f4 Add ability to load custom Tuya quirks (#166952) 2026-04-10 12:31:36 +02:00
Tomer
5edcfdf621 Mark docs-examples and docs-known-limitations as done for victron_gx (#167866)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 12:13:06 +02:00
Andre v d Walt
244ed14019 smartthings: add Samsung OCF AC purify switch (#167705) 2026-04-10 12:12:36 +02:00
Robert Resch
109ec0705c Use runtime_data in vilfo integration (#167886)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:54:32 +02:00
epenet
6f7fa85d18 Use runtime_data in system_bridge integration (#167880)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:53:53 +02:00
epenet
8d2564f00f Use runtime_data in soundtouch integration (#167869)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:52:47 +02:00
epenet
f7096e3744 Use runtime_data in srp_energy integration (#167870)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:51:29 +02:00
epenet
d7f28a09bb Use runtime_data in sleepiq integration (#167865)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:50:53 +02:00
epenet
a54ea071f8 Use runtime_data in Slack (#167864)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 11:50:19 +02:00
epenet
1597b740da Use runtime_data in skybell integration (#167862)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:48:48 +02:00
epenet
3758d606c9 Use runtime_data in simplisafe integration (#167858)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:47:58 +02:00
epenet
a79988aca7 Use runtime_data in sia integration (#167857)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:46:43 +02:00
epenet
837cd7d89d Use runtime_data in sanix integration (#167856)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:46:12 +02:00
Retha Runolfsson
038bb6c15d Add child lock and wireless charging switches for air purifier (#167140)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:29:02 +02:00
Paul Bottein
6ccede7f30 Add fabric index fields to Matter lock user and credential responses (#167875) 2026-04-10 11:18:10 +02:00
Abílio Costa
fb541d8835 Replace ding with new ring event in Ring integration doorbell (#167728) 2026-04-10 11:04:52 +02:00
epenet
39a2c08d4e Use runtime_data in switchbot_cloud integration (#167879)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:59:20 +02:00
epenet
ea642980f2 Use runtime_data in switchbee (#167878)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 10:45:04 +02:00
Renaud Allard
4c8ea3669c Load lovelace resource collection eagerly during setup (#165773) 2026-04-10 04:38:17 -04:00
epenet
14f24226ae Use runtime_data in streamlabswater (#167874)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:31:55 +02:00
epenet
3a9f805f10 Use runtime_data in surepetcare integration (#167877)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 10:31:42 +02:00
Maikel Punie
191dd42a92 Bump velbusaio to 2026.4.0 (#167868) 2026-04-10 09:59:53 +02:00
Daniel Hjelseth Høyer
35ffffb159 Improve Tibber price coordinator (#166175)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 09:57:32 +02:00
J. Nick Koston
4494f9ff6b Bump aioesphomeapi to 44.13.1 (#167855) 2026-04-10 09:57:28 +02:00
dependabot[bot]
09e6b6533a Bump dawidd6/action-download-artifact from 19 to 20 (#167861)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 09:56:29 +02:00
dependabot[bot]
c42e37dd7d Bump docker/login-action from 4.0.0 to 4.1.0 (#167860)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 09:55:57 +02:00
Simone Chemelli
853b6a80d2 Fix stale devices removal for Alexa devices (#167837) 2026-04-10 09:53:51 +02:00
TheJulianJES
eaa1fc591a Bump ZHA to 1.1.2 (#167849) 2026-04-10 09:52:19 +02:00
peteS-UK
3f388e88e0 Add support for deletion of stale devices for Squeezebox (#159848)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-04-10 09:47:20 +02:00
epenet
44eea221b7 Use runtime_data in Snooz (#167867)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:43:06 +02:00
Brendan McShane
6a3937b96b Add HomeKit AirPlay Enable (Ecobee) (#159564)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-10 09:33:22 +02:00
norkudev
1c5e020344 Include indirect automation references in device view (#167719) 2026-04-10 01:56:37 +02:00
Brett Adams
6ac7952f26 Tessie: use Vehicle methods for button commands (#167193)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:39:28 +02:00
J. Diego Rodríguez Royo
7f0d94da9f Fix service.yaml values for Home Connect (#167847) 2026-04-10 00:34:50 +02:00
epenet
8f383bccd9 Set assumed state on Renault number entity (#167644) 2026-04-10 00:20:47 +02:00
Fabian Munkes
8c50cb2ab1 Add initial support for PlayerOptions: Switch entities to Music Assistant (#167829)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-04-10 00:14:37 +02:00
David Bonnes
b0888b051c Improve services.yaml in Evohome to improve UI/UX (#167788)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 00:12:30 +02:00
Fabian Munkes
0764e3e239 Add support for sound modes to Music Assistant. (#167838) 2026-04-10 00:11:06 +02:00
Robin Thoni
cf4d8f0974 Add VoIP sensors to sfr_box (#166609)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-04-10 00:03:49 +02:00
Ronald van der Meer
e7e4c495fd Add Duco integration (#167220) 2026-04-09 23:54:31 +02:00
Matthias Alphart
8f6ae15a6a KNX: Configure entity expose from config panel UI (#167692) 2026-04-09 23:46:50 +02:00
David Bishop
910dcb4d68 Govee light local availability test cleanup (#167702)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:45:37 +02:00
G Johansson
86b5efaf2c Don't use async_update_reload_and_abort with update listeners in tele… (#167696) 2026-04-09 23:44:42 +02:00
Noah Husby
5f8483ba07 Add party mode to Russound RIO (#167342) 2026-04-09 23:40:15 +02:00
g4bri3lDev
496c9551b3 Add event platform for OpenDisplay (#167393) 2026-04-09 23:37:25 +02:00
puddly
2d45f9978e List serial ports via USB integration helpers (Q-Z) (#167701) 2026-04-09 17:24:36 -04:00
Petro31
caa1a8880f Allow trigger based template entities to skip option validation (#167708) 2026-04-09 23:23:37 +02:00
Jeef
1e78666b90 Prevent the intellifire client from polling independently of its coordinator (#165341)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 23:22:33 +02:00
Fabian Neundorf
53738c0168 Add 2fa support in picnic integration (#167636) 2026-04-09 23:09:35 +02:00
J. Diego Rodríguez Royo
ca96c751e1 Add delayed start as an operation state that flags as program running at Home Connect (#167549) 2026-04-09 23:03:03 +02:00
johanzander
2a0a386e6d Update Growatt quality scale: mark docs rules done and exempt discovery (#166075)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:59:50 +02:00
Michael
79dfa61e8b Add favorite collection to immich media source (#167841) 2026-04-09 22:51:32 +02:00
Kurt Chrisford
431387b76d Fix Actron Air quality scale rule statuses (#167149) 2026-04-09 22:47:59 +02:00
Tomer
f4a2f37fa6 Victron GX select platform (#167675)
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-04-09 22:45:47 +02:00
Fabian Munkes
ec54a121c1 Add initial support for PlayerOptions: Text entities to Music Assistant (#167832)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-04-09 22:10:20 +02:00
Petro31
f5d5ee71f5 Update template lock tests to use new framework (#164621) 2026-04-09 22:01:02 +02:00
Michael
7e1f4d27e8 Bump aioimmich to 0.14.0 (#167833) 2026-04-09 22:00:39 +02:00
mettolen
4700c79ace Implement reconfiguration flow for Huum integration (#167711) 2026-04-09 21:57:18 +02:00
Artur Pragacz
b6ea61f953 Fix run_then_background in service intent handler (#167817) 2026-04-09 21:37:16 +02:00
Tomer
1eab08f986 Victron GX binary_sensor platform (#167527)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 21:32:16 +02:00
Erwin Douna
f491ec8b44 Generate translations optimization (#166483)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 21:30:17 +02:00
Jamie Magee
e639e983dc Use offline command for non-UTF-8 stdout test (#167466) 2026-04-09 21:25:59 +02:00
David Bonnes
a983cb7ccd Tidy up Evohome code, and improve docstrings (#167827) 2026-04-09 21:18:01 +02:00
Petro31
89ddfff66f Update template switch tests to use new framework (#167826) 2026-04-09 21:16:21 +02:00
Petro31
77c8eab698 Update template update tests to use new framework (#167828) 2026-04-09 21:11:04 +02:00
Petro31
9ac730fb58 Update template vacuum tests to use new framework (#167830) 2026-04-09 21:07:02 +02:00
Raj Laud
6cc05e6a28 Fix Victron BLE false reauth triggered by unknown enum bitmask combinations (#167809)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 20:53:26 +02:00
Denis Shulyaka
75a4b088bc Entity translation for Anthropic integration (#166725)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 20:41:04 +02:00
Petro31
9056e0b64f Update template cover tests to use new framework (#167686) 2026-04-09 20:39:12 +02:00
Petro31
3a1002457b Update template number tests to use new framework (#167823) 2026-04-09 20:34:45 +02:00
Petro31
97fe710187 Update template select tests to use new framework (#167825) 2026-04-09 20:34:02 +02:00
Benjamin Hudgens
09585a7e1c Revert "Fix Ring snapshots" - #164337 (#167790) 2026-04-09 20:21:14 +02:00
puddly
6d55c076e4 List serial ports via USB integration helpers (A-P) (#167695) 2026-04-09 19:56:59 +02:00
Samuel Xiao
8b37cc8719 Switchbot Cloud: Enable Webhook for Bot (#165647) 2026-04-09 19:43:13 +02:00
Simone Chemelli
6510b3d1d1 Add configuration URL to Comelit (#167813) 2026-04-09 19:36:08 +02:00
David Bonnes
e5a83106d7 Change default icon of Evohome's WaterHeater entities (#167818) 2026-04-09 19:14:32 +02:00
Florent Thoumie
de973e8900 iaqualink: don't return False in async_setup_entry (#167812) 2026-04-09 17:59:52 +02:00
Petro31
fefc5a950f Update template binary sensor tests to use new framework (#167704) 2026-04-09 17:56:55 +02:00
Petro31
b3e7ae0fdd Update template alarm control panel tests to use new framework (#167799) 2026-04-09 17:56:24 +02:00
Petro31
7bad7fc4f6 Update template button tests to use new framework (#167806) 2026-04-09 17:53:11 +02:00
Petro31
36944525e1 Update template event tests to use new framework (#167808) 2026-04-09 17:52:51 +02:00
Barry vd. Heuvel
f3d25a04f8 Bump weheat to 2026.4.8 (#167807) 2026-04-09 17:50:18 +02:00
MoonDevLT
f2c20fedeb Add zeroconf discovery to Lunatone integration (#167582)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-09 17:31:01 +02:00
mcisk
93e9575547 Add reauthentication flow to Autoskope integration (#167688) 2026-04-09 17:30:53 +02:00
Brett Adams
681f8bedb4 Handle boolean charging state in Tessie sensor (#165172) 2026-04-09 17:26:53 +02:00
Lamarqe
566ff6d1d5 Add frequency unit conversion (#167537) 2026-04-09 17:16:02 +02:00
epenet
5bec3d1b41 Disable pilight integration (#167760) 2026-04-09 16:44:18 +02:00
MoonDevLT
050d929d8a Bump lunatone-rest-api-client to 0.9.1 (#167804) 2026-04-09 16:28:35 +02:00
Barry vd. Heuvel
15045f55d5 Make Weheat energy output TOTAL instead of TOTAL_INCREASING (#167761) 2026-04-09 15:42:57 +02:00
epenet
b2fb6c0a68 Use runtime_data in seventeentrack integration (#167737)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:27:15 +02:00
Tomer
66e35cef06 Bump victron-mqtt to 2026.4.3 (#167787) 2026-04-09 15:26:37 +02:00
Denis Shulyaka
9ea527520a Bump anthropic to 0.92.0 (#167793) 2026-04-09 15:18:21 +02:00
Retha Runolfsson
872120821c Fix SwitchBot encrypted device method selection not resetting on back (#167749)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-09 15:15:49 +02:00
wibbit
998f24649d geniushub: add water heater platform tests (#167763)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-09 15:11:41 +02:00
TimL
cc21c99e55 Fix "IR emitter" sentence case in SMLIGHT string (#167684) 2026-04-09 15:00:09 +02:00
MoonDevLT
4efb6b9b56 Add color modes to Lunatone light entity (#167574) 2026-04-09 14:22:44 +02:00
Marc Mueller
a9f0cd203c Update pytest warnings filter (#167703) 2026-04-09 14:20:59 +02:00
Maciej Bieniek
eb31499e78 Bump aiotractive to 1.0.2 (#167783) 2026-04-09 14:19:46 +02:00
TimL
d292aa2e90 Add missing exception string from smlight IR platform (#167784) 2026-04-09 13:50:30 +02:00
epenet
87f44a67be Use runtime_data in sharkiq integration (#167741)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:45:44 +02:00
epenet
efb0e80577 Use runtime_data in smart_meter_texas integration (#167743)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:44:34 +02:00
epenet
11c34c7ddf Use runtime_data in snapcast integration (#167744)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:40:11 +02:00
epenet
4b820a0204 Use runtime_data in somfy_mylink integration (#167745)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:39:19 +02:00
epenet
0c98f01b07 Use runtime_data in starline integration (#167746)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:37:59 +02:00
epenet
aa50822a82 Use runtime_data in Subaru integration (#167747)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:36:45 +02:00
epenet
f634525798 Use runtime_data in syncthing integration (#167748)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:34:42 +02:00
Denis Shulyaka
047500af42 Anthropic pretty device model name (#167772)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 13:33:02 +02:00
MoonDevLT
db589f7318 Bump lunatone-rest-api-client to 0.9.0 (#167762) 2026-04-09 13:20:13 +02:00
TimL
3ea15f2743 Refactor Ultima fixtures to reduce duplication (#167731) 2026-04-09 13:20:01 +02:00
Maciej Bieniek
8e430d9f26 Bump brother to 6.1.0 (#167768) 2026-04-09 13:19:05 +02:00
Michael
075b47b5f9 Set proper state for the internet_access switches in FRITZ!Box Tools (#167767) 2026-04-09 12:46:34 +02:00
wollew
949c907407 Bump pyvlx to 0.2.33 (#167764) 2026-04-09 11:55:32 +02:00
Franck Nijhof
326799209c Extract config entry template functions into a config entry Jinja2 extension (#167360)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-09 11:49:56 +02:00
Simone Chemelli
65bc7c9ea7 Allow force alarm actions for Comelit (#167202) 2026-04-09 11:16:05 +02:00
dependabot[bot]
86d443f8c6 Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#167648)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 10:29:18 +02:00
epenet
19ae7e722e Bump pybotvac to 0.0.29 (#167758) 2026-04-09 09:51:03 +02:00
Abílio Costa
57568fdc2c Add standard event type for doorbell event entities (#167630) 2026-04-09 00:02:05 +01:00
Oluwatobi Mustapha
4c8a660b2d Redact Z-Wave add-on options sensitive error details (#167239)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 21:17:26 +02:00
puddly
b0511519a1 Expose async serial port scanning helper in USB integration (#167706) 2026-04-08 14:29:27 -04:00
Marc Mueller
038b583888 Update types packages (#167700) 2026-04-08 19:20:57 +02:00
Raphael Hehl
018c130988 Update UniFi Access quality scale: mark documentation rules as done (#166898)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-08 16:18:19 +02:00
David Bishop
462e9965d7 Mark Govee local devices unavailable when they stop responding (#167566)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:18:55 +01:00
Franck Nijhof
ea4d85f96c Extract arithmetic template filters into the math Jinja2 extension (#167309)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-08 14:27:22 +02:00
Petro31
1a4d518ef2 Update template fan tests to use new framework (#167625) 2026-04-08 13:51:15 +02:00
TimL
a48a770ca4 Add Infrared platform to SMLIGHT (#167568) 2026-04-08 12:35:48 +01:00
Tom
e4aeee9d85 Fix ProxmoxVE migration causing reauthentication (#167624) 2026-04-08 13:22:25 +02:00
Raphael Hehl
726edf3a3b Unifi access protect api key hint (#167404)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-08 13:21:54 +02:00
epenet
b98aa0ad91 Use runtime_data in rdw integration (#167654)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:18:26 +02:00
David
f82b8cb7c7 Bump pylutron-caseta to 0.28.0 (#167642) 2026-04-08 13:17:45 +02:00
epenet
d6342d51cc Use runtime_data in radiotherm (#167650)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:16:21 +02:00
epenet
1eead15c24 Use runtime_data in Rabbit Air (#167649)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:15:46 +02:00
epenet
2e6137325c Use runtime_data in ridwell integration (#167658)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:14:23 +02:00
Kurt Chrisford
8d3d4a1b5c Add diagnostics to Actron Air (#167145) 2026-04-08 13:12:56 +02:00
Tomer
3e5132bf85 Victron GX reauthentication-flow (#167614)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-08 12:58:16 +02:00
epenet
65e4b26006 Use suggested uom in Renault charging power sensor (#167646) 2026-04-08 12:32:26 +02:00
epenet
13f1a42d69 Use runtime_data in roon integration (#167660)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:16:32 +02:00
epenet
5be48affcf Use runtime_data in rova integration (#167661)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:15:59 +02:00
epenet
8994f501f1 Use runtime_data in rympro integration (#167663)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:15:34 +02:00
epenet
7f49ecffd3 Use runtime_data in romy integration (#167665)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:14:29 +02:00
epenet
a560967861 Use runtime_data in roomba integration (#167667)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:44 +02:00
epenet
82202ee1c2 Use runtime_data in ruckus_unleashed integration (#167662)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:26 +02:00
Franck Nijhof
b697b3a54e Extract version template function into a version Jinja2 extension (#167172)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-08 12:10:22 +02:00
Kurt Chrisford
6cf5bbe2f5 Bump actronneoapi to 0.5.0 (#167669) 2026-04-08 12:06:48 +02:00
epenet
c0c61533e6 Use runtime_data in risco integration (#167659)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:05:04 +02:00
epenet
15e434431d Use runtime_data in renson integration (#167664)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:58:10 +02:00
epenet
0452bb91c7 Cleanup unused renault base entity method (#167643) 2026-04-08 11:57:55 +02:00
epenet
5620fc9e96 Use runtime_data in recollect_waste integration (#167655)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:57:44 +02:00
epenet
1a5ef199da Remove duplicated FlussConfigEntry type aliases (#167676)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:48:02 +02:00
Joost Lekkerkerker
e98eec113e Add DHCP discovery to MyStrom (#167084)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-04-08 11:36:20 +02:00
Mattheinrichs
c74d4047d8 Add diagnostics support to tplink_omada (#166802)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-04-08 10:37:01 +02:00
epenet
f5ae250720 Improve type hints in ipma system_health (#167670) 2026-04-08 10:30:27 +02:00
epenet
bea4eea871 Use runtime_data in rainforest_eagle integration (#167652)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:42:10 +02:00
Maciej Bieniek
f4f202a8a1 Fix Tractive switch availability (#167599) 2026-04-08 07:44:45 +02:00
Erwin Douna
c30ccf3750 Bump pyportainer 1.0.38 (#167627) 2026-04-08 05:36:10 +02:00
Raphael Hehl
0b8390cf21 Bump py-unifi-access to 1.1.5 (#167633)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-08 00:47:58 +02:00
Joakim Plate
1a048a7845 Move logging for loading/unloading config entry to integration logger (#167415) 2026-04-07 23:43:11 +02:00
Erik Montnemery
08097c67eb Bump securetar to 2026.4.1 (#167617) 2026-04-07 20:19:51 +01:00
Oliver Verity
550e53d192 Add support for storing OpenAI conversation responses (#165723) 2026-04-07 20:19:25 +01:00
Fabian Munkes
09ee76c265 Add initial support for PlayerOptions: Number entities to Music Assistant (#162669)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-04-07 20:51:26 +02:00
Norbert Rittel
f7b2f5e8f1 Improve Remote action naming consistency (#167382) 2026-04-07 18:48:05 +02:00
G Johansson
a1414717ad Bump holidays to 0.94 (#167604) 2026-04-07 18:41:28 +02:00
Erik Montnemery
2f0889ac02 Fix securetar size calculation when encrypting backup (#167602) 2026-04-07 18:40:06 +02:00
Joakim Plate
323b3a4d96 Add contour and position names to gardena (#167512) 2026-04-07 18:29:05 +02:00
Erwin Douna
8aa0e9f6c3 Refactor to active_containers (#167529) 2026-04-07 18:27:43 +02:00
Erik Montnemery
906475249c Bump securetar to 2026.4.0 (#167600) 2026-04-07 16:00:06 +02:00
Oliver Verity
354b5860bb Add read-only MCP Assist context snapshot resource (#167396) 2026-04-07 06:57:55 -07:00
Ludovic BOUÉ
74957969f7 Add select entities for Roborock q10 s5+ (#166142)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-07 06:55:43 -07:00
Leo Periou
b52ce22ee7 fix EWS deviceType problem (#167597) 2026-04-07 15:49:21 +02:00
Jan Čermák
920ffdb9b5 Remove homeassistant/actions/helpers/info from builder workflow (#167573) 2026-04-07 14:52:05 +02:00
Artur Pragacz
4a454dff02 Set up condition and trigger helpers in check config script (#167589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 14:50:37 +02:00
Stefan S
481eb66bc5 Add unit 'µA' for the units of electric current (#166786)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-04-07 14:48:21 +02:00
Retha Runolfsson
b76627a442 Add light sensor button to switchbot air purifier (#167134)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:13:35 +02:00
Denis Shulyaka
1aa214fb61 Enable minimal thinking budget by default for Anthropic integration (#167593) 2026-04-07 13:36:49 +02:00
markhannon
6e30de3a1c Bump zcc-helper to 3.8 (#167555) 2026-04-07 13:31:51 +02:00
Kevin McCormack
2f0488f985 Opnsense swap to aiopnsense (#167026)
Co-authored-by: Snuffy2 <Snuffy2@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-07 13:19:49 +02:00
Petar Petrov
61c02c854f Add websocket subscription support for calendar events (#156340)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 14:04:51 +03:00
Nelson Osacky
a10f16ce3e Add missing Miele dishwasher program ID 201 (#167536)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:47:48 +02:00
J. Diego Rodríguez Royo
293db47101 Bump aiohomeconnect to 0.34.0 (#167592) 2026-04-07 12:47:03 +02:00
Tomer
c9cdbcc3db Bump victron-mqtt to 2026.4.2 (#167565) 2026-04-07 12:46:38 +02:00
Simone Chemelli
856d363ca8 Align Fritz test data mock to real implementation (#167511) 2026-04-07 12:39:13 +02:00
Artur Pragacz
cb71628ee2 Validate entity ID domain (#167228) 2026-04-07 12:36:37 +02:00
Fabian Neundorf
f34301f236 Bump python-picnic-api2 to 1.3.4 (#167539)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-07 12:20:22 +02:00
epenet
554b906788 Migrate qnap_qsw to use runtime_data (#167200)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:16:06 +02:00
epenet
d5ffd7f37a Improve typing in Renault sensor descriptions (#167571) 2026-04-07 12:13:19 +02:00
epenet
17fbb9909c Improve typing in Renault binary_sensor descriptions (#167577) 2026-04-07 12:11:32 +02:00
epenet
bd4ac7993f Improve typing in Renault select descriptions (#167578) 2026-04-07 12:09:30 +02:00
epenet
eaba7b0e48 Improve typing in Renault number descriptions (#167580) 2026-04-07 12:08:12 +02:00
Stefan Agner
2a8551138d Reject backup uploads with invalid filenames (#167211)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:34:29 +02:00
epenet
7586fe6dec Remove unnecessary Renault entity description mixin (#167579) 2026-04-07 09:52:40 +02:00
Nils Ove Erstad
74a6f781a1 Fix missing color_mode initialization in MQTT JSON light schema (#167429) 2026-04-07 09:20:48 +02:00
Norbert Rittel
4dba27f15e Replace "custom" with "community" in analytics_insights (#167506) 2026-04-07 01:19:03 +03:00
David Bonnes
298b9b962f Use fixtures for all entity IDs in Evohome tests (#167479)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-06 23:58:11 +02:00
Erwin Douna
f6fb6f40dd Proxmox refactor coordinator typing (#167500) 2026-04-06 22:11:37 +01:00
Noah Groß
12ee952a97 Use retry_after for UpdateFailed exception in Picnic (#167525) 2026-04-06 22:08:10 +01:00
Erwin Douna
9d6a335127 Refactor enums in Portainer (#167540) 2026-04-06 22:05:10 +01:00
Norbert Rittel
e1fa894572 Improve Activity (logbook) action naming consistency (#167533) 2026-04-06 22:00:09 +01:00
Norbert Rittel
43c83cc850 Improve Persistent Notification action naming consistency (#167531) 2026-04-06 21:57:03 +01:00
Tomer
7f27915825 Bump victron-mqtt to 2026.4.1 (#167547) 2026-04-06 21:56:22 +01:00
Jan Bouwhuis
54a63d2c3e Bump incomfort-client to v0.7.0 (#167546) 2026-04-06 22:37:38 +02:00
Erwin Douna
9709fbd6c6 Bump pyportainer 1.0.37 (#167535)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-06 22:36:09 +02:00
puddly
12abff5b9e Allow listing non-USB serial ports with scan_serial_ports (#166029)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-06 14:14:08 -04:00
J. Diego Rodríguez Royo
9b2abb0acc Move program_running to a property at Home Connect sensors (#167523)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-06 20:12:34 +02:00
johnmph
e7bc593fa8 Add support for hiding walls and rooms in map rendering (#162053)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-04-06 18:49:28 +01:00
Norbert Rittel
2a4b8c88a8 Improve Google Cast action naming consistency (#167152) 2026-04-06 19:20:38 +02:00
Erwin Douna
a6c66a86ee Tado add rate limit indicator (#164132)
Co-authored-by: abmantis <amfcalt@gmail.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-04-06 18:00:16 +01:00
Joost Lekkerkerker
6dfcc1c4f6 Always use quality scale shorthand rule notation (#167516) 2026-04-06 18:36:46 +02:00
mettolen
dfb61ee881 Mark Huum documenation quality scale items done (#167413) 2026-04-06 17:38:31 +02:00
Daniel Hjelseth Høyer
4776b99f5f Bump pyTibber to 0.37.0 (#167283) 2026-04-06 17:29:46 +02:00
Ludovic BOUÉ
49f5557947 Add sensor entities for Roborock q10 s5+ (#166120)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-06 17:13:44 +02:00
Ludovic BOUÉ
4ed9113e35 Bump python-roborock from 5.3.0 to 5.5.1 (#167520)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-06 16:57:02 +02:00
Norbert Rittel
e2688f909b Update "custom component" to "community integration" in Shelly (#167515) 2026-04-06 16:39:35 +03:00
Mike O'Driscoll
9e1c521fed Casper Glow: add dimming end time (#166769) 2026-04-06 15:09:18 +02:00
Norbert Rittel
73fbc87639 Replace 'custom component' with 'community integration' in bmw_connected_drive (#167505) 2026-04-06 15:35:47 +03:00
Norbert Rittel
8986477c96 Replace "custom" with "community" in homeassistant (#167507) 2026-04-06 15:31:25 +03:00
Jordan Harvey
ca40d68417 Bump pynintendoparental to 2.3.4 (#167510) 2026-04-06 15:29:54 +03:00
Erwin Douna
d2539ccaf2 Add resume button to Proxmox qemu (#167329) 2026-04-06 13:02:03 +02:00
Tom
56e7b8ddbb Improve ProxmoxVE permissions handling (#167370) 2026-04-06 13:00:14 +02:00
Tomer
ca5aa215d2 Victron GX communication center integration (#156090)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-06 12:57:12 +02:00
Thomas55555
d3ca5132fc Fix polling pause in Husqvarna Automower (#167397)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-06 12:51:42 +02:00
Norbert Rittel
f1d309779e Improve Profiler action naming consistency (#167349)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-06 12:49:53 +02:00
Norbert Rittel
85fa2415c1 Fix name of shopping_list.add_item action (#167352) 2026-04-06 12:49:23 +02:00
Franck Nijhof
ddc00f6924 Extract functional utility template functions into a functional Jinja2 extension (#167357) 2026-04-06 12:48:15 +02:00
Raphael Hehl
2216fcccc7 Handle access.logs.add events for UniFi Access G6 Pro Entry (#167362)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-06 12:47:26 +02:00
Robert Svensson
6212d548b8 Bump axis to v68 to improve MQTT event resiliance (#167373) 2026-04-06 12:43:57 +02:00
Shai Ungar
bf12323782 Use dedicated session for seventeentrack to preserve login cookies (#167394)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:39:49 +02:00
Willem-Jan van Rootselaar
8b9ba690f1 Include port in BSB-LAN configuration URL when non-default (#166480) 2026-04-06 12:38:55 +02:00
Willem-Jan van Rootselaar
3c7c0091f2 Fix setup without dhw (#167423) 2026-04-06 12:38:27 +02:00
Norbert Rittel
7bea4a53e2 Improve google_photos action naming consistency (#167146) 2026-04-06 12:37:37 +02:00
Norbert Rittel
50d9109e5f Improve Google Calendar action naming consistency (#167143) 2026-04-06 12:37:29 +02:00
Denis Shulyaka
6d80b3769a Promote Anthropic to Silver (#167361) 2026-04-06 12:35:44 +02:00
Pete Sage
9ff97ecd7b Remove if statements in Sonos Media Player Tests (#167210) 2026-04-06 12:35:32 +02:00
Erwin Douna
34dc52c732 Add better pause behaviour to Portainer (#167453) 2026-04-06 12:33:33 +02:00
TimL
67243a5044 Revert "Add Remote platform to SMLIGHT Integration (#166728)" (#167424) 2026-04-06 12:31:31 +02:00
cdheiser
74770f0b33 Bump pylutron to 0.4.1 (#167324) 2026-04-06 12:31:10 +02:00
Steve Easley
b1d81536ce Bump jvcprojector dependency to pyjvcprojector 2.0.5 (#167450) 2026-04-06 12:30:55 +02:00
Jamie Magee
b8d8b1cfa8 Fix nzbget positional argument mismatch in NZBGetAPI calls (#167456) 2026-04-06 12:29:27 +02:00
Jordan Harvey
dfaed39a01 Fix handling of missing period statistics in Anglian Water coordinator (#167427)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-06 12:28:35 +02:00
Jamie Magee
4849dc0eb9 Fix modem_callerid test_setup_entry mock (#167461) 2026-04-06 12:28:16 +02:00
Denis Shulyaka
05aaf8745d Add Tool search tool to Anthropic (#167484) 2026-04-06 12:24:07 +02:00
Cameron Jones
7c1abd993d Add notify_on_use param to Schlage add_code service (#167402) 2026-04-06 12:23:49 +02:00
Nick Haghiri
0674de2ce4 Handle BadRequest exception in Backblaze B2 config flow and setup (#167482) 2026-04-06 12:12:55 +02:00
Nick Haghiri
ba26d119f7 Bump b2sdk to 2.10.4 (#167481) 2026-04-06 12:11:25 +02:00
Allen Porter
616a0f204c Migrate Fitbit user profile fetching to use fitbit-web-api (#167480) 2026-04-06 12:10:29 +02:00
Andrea Turri
0eaa8d38db Miele - fix core temperature reading (#167476) 2026-04-06 12:08:29 +02:00
Mike O'Driscoll
4ad2f752a3 Fix flaky fire_callbacks tests in casper_glow (#167418) 2026-04-06 12:06:03 +02:00
Allen Porter
310af5a31a Update roborock services to raise ServiceNotSupported for new devices that don't yet support it (#167470)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-06 12:05:06 +02:00
Chase
11fac8ee48 OpenRouter: Update quality scale (#166921) 2026-04-06 12:04:32 +02:00
Allen Porter
e70514f540 Bump pyrainbird to 6.3.0 (#167493) 2026-04-06 11:53:27 +02:00
dependabot[bot]
1a7465dd72 Bump home-assistant/actions from 5f5b077d63a1e4c53019231409a0c4d791fb74e5 to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 (#167494)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 11:51:59 +02:00
dependabot[bot]
15ddce74a7 Bump github/codeql-action from 4.32.6 to 4.35.1 (#167495)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 11:46:32 +02:00
Simone Chemelli
ee4c941610 Update IQS to gold for Fritz (#167358) 2026-04-06 11:24:34 +02:00
Simone Chemelli
459fc43625 Migrate wifi switch unique_id for Fritz (#166751) 2026-04-06 11:18:42 +02:00
Erwin Douna
893d9306d4 Refactor async_setup in Firefly III (#167496) 2026-04-06 11:09:19 +02:00
Erwin Douna
c243680113 Add coordinator update after press call in Portainer (#167497) 2026-04-06 11:05:33 +02:00
J. Diego Rodríguez Royo
db6d95273c Add microwave to some sensors' related appliance types (#167501) 2026-04-06 11:05:02 +02:00
Manu
a084e85028 Add mastodon.update_profile action to Mastodon integration (#167444) 2026-04-05 15:23:26 -07:00
David Bonnes
1d7eb5ed15 Use mark.usefixtures for unreferenced fixtures in Evohome (#167467) 2026-04-05 21:31:57 +02:00
Jamie Magee
7b19b5a416 Fix overseerr test importing from future.backports (#167458) 2026-04-05 20:10:05 +02:00
Denis Shulyaka
2b0f2bcd12 Automatic caching support for Anthropic (#167436) 2026-04-05 20:10:08 +03:00
Jordan Harvey
5d5d00f0b2 Bump cryptography to 46.0.6 (#167330)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-04-05 17:56:01 +02:00
Raphael Hehl
735adb61a3 Fix blocking SSL context creation in unifi_access integration (#167422) 2026-04-05 17:32:23 +02:00
Daniel Feinberg
42f602a2fd Bump python-roborock from 5.0.0 to 5.3.0 (#167437)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 07:25:01 -07:00
TimL
287b38bebd Bump pysmlight to 0.3.2 (#167421) 2026-04-05 12:02:59 +02:00
Simone Chemelli
415f711039 Bump aiocomelit to 2.0.2 (#167414) 2026-04-05 12:02:32 +02:00
G-Two
28ddb3a590 Bump subarulink to 0.7.19 (#167386) 2026-04-05 12:01:40 +02:00
lzghzr
0f1dbba65a Bump xiaomi-ble to 1.10.1 (#167384) 2026-04-05 11:59:31 +02:00
Patrick
206d1ab3a8 Bump starlink-grpc-core to 1.2.5 (#167195) 2026-04-05 11:58:46 +02:00
Marco Sousa
cb15014dc0 Bump aiopvpc to 4.3.1 (#167189) 2026-04-05 11:57:27 +02:00
Simone Chemelli
a30fc9e9d5 Align and cleanup tests data for Fritz (#167363) 2026-04-05 10:43:24 +02:00
Jonas
b5480da68e Add manual and remaining watering time to Gardena Bluetooth for Aquaprecise (#167381) 2026-04-04 21:13:57 +02:00
Joost Lekkerkerker
a5a8cca424 Bump aiohue to 4.8.1 (#167369) 2026-04-04 20:59:34 +02:00
Niracler
5c890a8a2b Use exception translations in sunricher_dali (#166858)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-04 16:43:56 +02:00
Jan Weltmeyer
dc45f86beb Add battery and supply voltage sensors to traccar_server (#167247)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 16:43:45 +02:00
MoonDevLT
5ef506623d Change attribute that is used as unique ID for lunatone (#165200)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-04 16:07:45 +02:00
Matt Philips
5632d308dd Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 15:50:35 +02:00
Niracler
43210b845b Mark icon-translations as exempt in sunricher_dali (#166857)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 15:35:25 +02:00
Erwin Douna
d798aad2d7 Add kill button to Portainer (#167277)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 15:16:39 +02:00
007hacky007
4232db480a Bump afsapi to 0.3.1 (#167321) 2026-04-04 14:55:36 +02:00
J. Nick Koston
8b7d1f60a0 Bump dbus-fast to 4.0.4 (#167135) 2026-04-04 13:55:47 +02:00
Simone Chemelli
cb69e638b4 100% coverage of device_tracker for Vodafone Station (#165824) 2026-04-04 13:04:56 +02:00
Tom Matheussen
f3a020780d Bump satel_integra to 1.1.0 (#167353) 2026-04-04 12:29:24 +02:00
Erwin Douna
d1224e3f92 Refactor Proxmox async_setup (#167328) 2026-04-04 12:09:49 +02:00
Franck Nijhof
ef8a0a404c Fix lingering task in TTS stream override tests (#167356) 2026-04-04 12:06:26 +02:00
Simone Chemelli
c86eb38cdb Automate device tracker cleanup process for Fritz (#166864) 2026-04-04 11:01:19 +02:00
Franck Nijhof
2333e9e8b1 Extract serialization template functions into a serialization Jinja2 extension (#167332) 2026-04-04 10:08:54 +02:00
J. Nick Koston
f8476e4e84 Bump habluetooth to 6.0.0 (#167340) 2026-04-03 17:50:47 -10:00
Noah Husby
d904175a2f Bump aiorussound to 4.10.0 (#167341) 2026-04-04 05:36:33 +02:00
Franck Nijhof
193720b4c6 Pin actions/helpers/info to fix release build (#167327) 2026-04-03 22:51:44 +02:00
Marc Mueller
409b3f17db Fix apple_tv RuntimeWarnings in tests (#167325) 2026-04-03 21:59:05 +02:00
Norbert Rittel
c3c1c3eb46 Improve Shopping List action naming consistency (#167248) 2026-04-03 22:57:00 +03:00
1084 changed files with 41713 additions and 11264 deletions

View File

@@ -47,10 +47,6 @@ jobs:
with:
python-version-file: ".python-version"
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
- name: Get version
id: version
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
@@ -112,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -123,7 +119,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -348,13 +344,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -503,7 +499,7 @@ jobs:
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
@@ -527,7 +523,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: "/language:python"

View File

@@ -332,6 +332,7 @@ homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.liebherr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linkplay.*

10
CODEOWNERS generated
View File

@@ -418,6 +418,8 @@ CLAUDE.md @home-assistant/core
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duco/ @ronaldvdmeer
/tests/components/duco/ @ronaldvdmeer
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
@@ -503,6 +505,8 @@ CLAUDE.md @home-assistant/core
/tests/components/essent/ @jaapp
/homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/eurotronic_cometblue/ @rikroe
/tests/components/eurotronic_cometblue/ @rikroe
/homeassistant/components/event/ @home-assistant/core
/tests/components/event/ @home-assistant/core
/homeassistant/components/evohome/ @zxdavb
@@ -1263,8 +1267,8 @@ CLAUDE.md @home-assistant/core
/tests/components/openuv/ @bachya
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/homeassistant/components/opnsense/ @mtreinish
/tests/components/opnsense/ @mtreinish
/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2
/tests/components/opnsense/ @HarlemSquirrel @Snuffy2
/homeassistant/components/opower/ @tronikos
/tests/components/opower/ @tronikos
/homeassistant/components/oralb/ @bdraco @Lash-L
@@ -1875,6 +1879,8 @@ CLAUDE.md @home-assistant/core
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_gx/ @tomer-w
/tests/components/victron_gx/ @tomer-w
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW

View File

@@ -1,5 +1,5 @@
{
"domain": "victron",
"name": "Victron",
"integrations": ["victron_ble", "victron_remote_monitoring"]
"integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"]
}

View File

@@ -1,11 +1,7 @@
"""The Actron Air integration."""
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
)
from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError
from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
@@ -25,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
"""Set up Actron Air integration from a config entry."""
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
systems: list[ActronAirACSystem] = []
systems: list[ActronAirSystemInfo] = []
try:
systems = await api.get_ac_systems()
@@ -44,9 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
await coordinator.async_config_entry_first_refresh()
system_coordinators[system["serial"]] = coordinator
system_coordinators[system.serial] = coordinator
entry.runtime_data = ActronAirRuntimeData(
api=api,

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
PARALLEL_UPDATES = 0
@@ -136,19 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@handle_actron_api_errors
@actron_air_command
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@handle_actron_api_errors
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@handle_actron_api_errors
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -212,13 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
@handle_actron_api_errors
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
@handle_actron_api_errors
@actron_air_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -38,10 +38,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response["device_code"]
self._user_code = device_code_response["user_code"]
self._verification_uri = device_code_response["verification_uri_complete"]
self._expires_minutes = str(device_code_response["expires_in"] // 60)
self._device_code = device_code_response.device_code
self._user_code = device_code_response.user_code
self._verification_uri = device_code_response.verification_uri_complete
self._expires_minutes = str(device_code_response.expires_in // 60)
async def _wait_for_authorization() -> None:
"""Wait for the user to authorize the device."""

View File

@@ -6,12 +6,12 @@ from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -38,7 +38,7 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
"""System coordinator for Actron Air integration."""
def __init__(
@@ -46,7 +46,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
hass: HomeAssistant,
entry: ActronAirConfigEntry,
api: ActronAirAPI,
system: ActronAirACSystem,
system: ActronAirSystemInfo,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -57,7 +57,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
config_entry=entry,
)
self.system = system
self.serial_number = system["serial"]
self.serial_number = system.serial
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()

View File

@@ -0,0 +1,35 @@
"""Diagnostics support for Actron Air."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from .coordinator import ActronAirConfigEntry
TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinators: dict[int, Any] = {}
for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()):
coordinators[idx] = {
"system": async_redact_data(
coordinator.system.model_dump(mode="json"), TO_REDACT
),
"status": async_redact_data(
coordinator.data.model_dump(mode="json", exclude={"last_known_state"}),
TO_REDACT,
),
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"coordinators": coordinators,
}

View File

@@ -14,10 +14,14 @@ from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
def actron_air_command[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
"""Decorator for Actron Air API calls.
Handles ActronAirAPIError exceptions, and requests a coordinator update
to update the status of the devices as soon as possible.
"""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
@@ -30,6 +34,7 @@ def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(self.coordinator.data)
return wrapper

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.4.1"]
"requirements": ["actron-neo-api==0.5.0"]
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
@@ -54,15 +54,9 @@ rules:
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category:
status: exempt
comment: This integration does not use entity categories.
entity-device-class:
status: exempt
comment: This integration does not use entity device classes.
entity-disabled-by-default:
status: exempt
comment: Not required for this integration at this stage.
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: done
icon-translations: todo

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, handle_actron_api_errors
from .entity import ActronAirAcEntity, actron_air_command
PARALLEL_UPDATES = 0
@@ -105,12 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@handle_actron_api_errors
@actron_air_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@handle_actron_api_errors
@actron_air_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -54,7 +54,16 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
self.previous_devices: set[str] = set()
device_registry = dr.async_get(hass)
self.previous_devices: set[str] = {
identifier
for device in device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
)
if device.entry_type != dr.DeviceEntryType.SERVICE
for identifier_domain, identifier in device.identifiers
if identifier_domain == DOMAIN
}
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""

View File

@@ -11,12 +11,12 @@
"user": {
"data": {
"tracked_apps": "Apps",
"tracked_custom_integrations": "Custom integrations",
"tracked_custom_integrations": "Community integrations",
"tracked_integrations": "Integrations"
},
"data_description": {
"tracked_apps": "Select the apps you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track",
"tracked_custom_integrations": "Select the community integrations you want to track",
"tracked_integrations": "Select the integrations you want to track"
}
}
@@ -31,7 +31,7 @@
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"custom_integrations": {
"name": "{custom_integration_domain} (custom)",
"name": "{custom_integration_domain} (community)",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"total_active_installations": {

View File

@@ -92,6 +92,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
allow_update_last_stored_hour = False
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
@@ -107,6 +108,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
stats: dict[str, list[Any]] = {}
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
@@ -127,15 +129,28 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
if not stats or not stats.get(usage_statistic_id):
_LOGGER.debug(
"Could not find existing statistics during period lookup for %s, "
"falling back to last stored statistic",
usage_statistic_id,
)
allow_update_last_stored_hour = True
last_records = last_stat[usage_statistic_id]
usage_sum = float(last_records[0].get("sum") or 0.0)
last_stats_time = last_records[0]["start"]
else:
allow_update_last_stored_hour = False
records = stats[usage_statistic_id]
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(records)
last_stats_time = records[0]["start"]
usage_statistics = []
@@ -148,7 +163,13 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
if last_stats_time is not None and (
start.timestamp() < last_stats_time
or (
start.timestamp() == last_stats_time
and not allow_update_last_stored_hour
)
):
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])

View File

@@ -37,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
coordinator = AnthropicCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
LOGGER.debug("Available models: %s", coordinator.data)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Mapping
import json
import logging
import re
from typing import TYPE_CHECKING, Any, cast
import anthropic
@@ -48,10 +47,12 @@ from .const import (
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -65,8 +66,11 @@ from .const import (
DOMAIN,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -101,34 +105,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
await client.models.list(timeout=10.0)
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id != "claude-3-haiku-20240307"
and model_info.id[-2:-1] != "-"
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
@@ -225,6 +201,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing conversation subentries."""
options: dict[str, Any]
model_info: anthropic.types.ModelInfo
@property
def _is_new(self) -> bool:
@@ -338,15 +315,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Manage advanced options."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
step_schema: VolDictType = {
vol.Optional(
CONF_CHAT_MODEL,
default=DEFAULT[CONF_CHAT_MODEL],
): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
),
vol.Optional(
CONF_MAX_TOKENS,
@@ -356,11 +332,40 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_PROMPT_CACHING,
default=DEFAULT[CONF_PROMPT_CACHING],
): SelectSelector(
SelectSelectorConfig(
options=[x.value for x in PromptCaching],
translation_key=CONF_PROMPT_CACHING,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
if user_input is not None:
self.options.update(user_input)
coordinator = self._get_entry().runtime_data
self.model_info, status = coordinator.get_model_info(
self.options[CONF_CHAT_MODEL]
)
if not status:
# Couldn't find the model in the cached list, try to fetch it directly
client = coordinator.client
try:
self.model_info = await client.models.retrieve(
self.options[CONF_CHAT_MODEL], timeout=10.0
)
except anthropic.NotFoundError:
errors[CONF_CHAT_MODEL] = "model_not_found"
except anthropic.AnthropicError as err:
errors[CONF_CHAT_MODEL] = "api_error"
description_placeholders["message"] = (
err.message if isinstance(err, anthropic.APIError) else str(err)
)
if not errors:
return await self.async_step_model()
@@ -370,6 +375,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Schema(step_schema), self.options
),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_model(
@@ -454,6 +460,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
CONF_TOOL_SEARCH,
default=DEFAULT[CONF_TOOL_SEARCH],
)
] = bool
else:
self.options.pop(CONF_TOOL_SEARCH, None)
if not step_schema:
user_input = {}
@@ -489,13 +505,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True,
)
async def _get_model_list(self) -> list[SelectOptionDict]:
def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
client = anthropic.AsyncAnthropic(
api_key=self._get_entry().data[CONF_API_KEY],
http_client=get_async_client(self.hass),
)
return await get_model_list(client)
coordinator = self._get_entry().runtime_data
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(model_info.id),
)
for model_info in coordinator.data or []
]
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""

View File

@@ -1,5 +1,6 @@
"""Constants for the Anthropic integration."""
from enum import StrEnum
import logging
DOMAIN = "anthropic"
@@ -13,9 +14,11 @@ CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT_CACHING = "prompt_caching"
CONF_TEMPERATURE = "temperature"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
@@ -24,20 +27,31 @@ CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
class PromptCaching(StrEnum):
"""Prompt caching options."""
OFF = "off"
PROMPT = "prompt"
AUTOMATIC = "automatic"
MIN_THINKING_BUDGET = 1024
DEFAULT = {
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: 0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_WEB_SEARCH_MAX_USES: 5,
}
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-haiku",
]
@@ -81,6 +95,11 @@ PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -2,7 +2,8 @@
from __future__ import annotations
from datetime import timedelta
import datetime
import re
import anthropic
@@ -15,13 +16,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
class AnthropicCoordinator(DataUpdateCoordinator[None]):
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
@@ -42,16 +58,16 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
)
@callback
def async_set_updated_data(self, data: None) -> None:
def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None:
"""Manually update data, notify listeners and update refresh interval."""
self.update_interval = UPDATE_INTERVAL_CONNECTED
super().async_set_updated_data(data)
async def async_update_data(self) -> None:
async def async_update_data(self) -> list[anthropic.types.ModelInfo]:
"""Fetch data from the API."""
try:
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
await self.client.models.list(timeout=10.0)
result = await self.client.models.list(timeout=10.0)
self.update_interval = UPDATE_INTERVAL_CONNECTED
except anthropic.APITimeoutError as err:
raise TimeoutError(err.message or str(err)) from err
@@ -67,6 +83,7 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
return result.data
def mark_connection_error(self) -> None:
"""Mark the connection as having an error and reschedule background check."""
@@ -76,3 +93,23 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
self.async_update_listeners()
if self._listeners and not self.hass.is_stopping:
self._schedule_refresh()
@callback
def get_model_info(self, model_id: str) -> tuple[anthropic.types.ModelInfo, bool]:
"""Get model info for a given model ID."""
# First try: exact name match
for model in self.data or []:
if model.id == model_id:
return model, True
# Second try: match by alias
alias = model_alias(model_id)
for model in self.data or []:
if model_alias(model.id) == alias:
return model, True
# Model not found, return safe defaults
return anthropic.types.ModelInfo(
type="model",
id=model_id,
created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC),
display_name=alias,
), False

View File

@@ -58,6 +58,8 @@ from anthropic.types import (
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam,
ToolSearchToolBm25_20251119Param,
ToolSearchToolResultBlock,
ToolUnionParam,
ToolUseBlock,
ToolUseBlockParam,
@@ -74,6 +76,9 @@ from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -91,9 +96,11 @@ from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT_CACHING,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -109,6 +116,7 @@ from .const import (
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
PromptCaching,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
@@ -202,7 +210,7 @@ class ContentDetails:
]
def _convert_content(
def _convert_content( # noqa: C901
chat_content: Iterable[conversation.Content],
) -> tuple[list[MessageParam], str | None]:
"""Transform HA chat_log content into Anthropic API format."""
@@ -255,6 +263,15 @@ def _convert_content(
content.tool_result,
),
}
elif content.tool_name == "tool_search":
tool_result_block = {
"type": "tool_search_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
ToolSearchToolResultBlockParamContentParam,
content.tool_result,
),
}
else:
tool_result_block = {
"type": "tool_result",
@@ -385,6 +402,7 @@ def _convert_content(
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_bm25",
],
tool_call.tool_name,
),
@@ -397,6 +415,7 @@ def _convert_content(
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_bm25",
]
else ToolUseBlockParam(
type="tool_use",
@@ -558,6 +577,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
if content_details:
@@ -669,16 +689,21 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
super().__init__(entry.runtime_data)
self.entry = entry
self.subentry = subentry
coordinator = entry.runtime_data
self.model_info, _ = coordinator.get_model_info(
subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
)
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
model=self.model_info.display_name,
model_id=self.model_info.id,
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
async def _async_handle_chat_log( # noqa: C901
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
@@ -688,21 +713,20 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"""Generate an answer for the chat log."""
options = self.subentry.data
preloaded_tools = [
"HassTurnOn",
"HassTurnOff",
"GetLiveContext",
"code_execution",
"web_search",
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
TextBlockParam(
type="text",
text=system.content,
cache_control={"type": "ephemeral"},
)
]
messages, container_id = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
@@ -711,11 +735,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
system=system_prompt,
system=system.content,
stream=True,
container=container_id,
)
if (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.PROMPT
):
model_args["system"] = [
{
"type": "text",
"text": system.content,
"cache_control": {"type": "ephemeral"},
}
]
elif (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.AUTOMATIC
):
model_args["cache_control"] = {"type": "ephemeral"}
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
@@ -874,8 +915,23 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
),
)
)
preloaded_tools.append(structure_name)
if tools:
if (
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
and len(tools) > len(preloaded_tools) + 1
):
for tool in tools:
if not tool["name"].endswith(tuple(preloaded_tools)):
tool["defer_loading"] = True
tools.append(
ToolSearchToolBm25_20251119Param(
type="tool_search_tool_bm25_20251119",
name="tool_search_tool_bm25",
)
)
model_args["tools"] = tools
coordinator = self.entry.runtime_data
@@ -918,7 +974,8 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
except anthropic.AnthropicError as err:
# Non-connection error, mark connection as healthy
coordinator.async_set_updated_data(None)
coordinator.async_set_updated_data(coordinator.data)
LOGGER.error("Error while talking to Anthropic: %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
@@ -930,7 +987,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
if not chat_log.unresponded_tool_results:
coordinator.async_set_updated_data(None)
coordinator.async_set_updated_data(coordinator.data)
break

View File

@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["anthropic==0.83.0"]
"quality_scale": "silver",
"requirements": ["anthropic==0.92.0"]
}

View File

@@ -81,7 +81,10 @@ rules:
status: exempt
comment: |
No entities disabled by default.
entity-translations: todo
entity-translations:
status: exempt
comment: |
Entities explicitly set `_attr_name` to `None`, so entity name translations are not used.
exception-translations: done
icon-translations: done
reconfiguration-flow: done

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING
import anthropic
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -18,8 +19,8 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from .config_flow import get_model_list
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -61,7 +62,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
client = entry.runtime_data.client
model_list = [
model_option
for model_option in await get_model_list(client)
for model_option in await self.get_model_list(client)
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
self._model_list_cache[entry.entry_id] = model_list
@@ -107,6 +108,22 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
},
)
async def get_model_list(
self, client: anthropic.AsyncAnthropic
) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
models = (await client.models.list(timeout=10.0)).data
except anthropic.AnthropicError:
models = []
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(model_info.id),
)
for model_info in models
]
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
"""Yield entry/subentry pairs that use deprecated models."""
for entry in self.hass.config_entries.async_entries(DOMAIN):

View File

@@ -38,6 +38,10 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "AI task",
"error": {
"api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]",
"model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]"
},
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
@@ -47,11 +51,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"data_description": {
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
@@ -72,6 +78,7 @@
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
@@ -80,6 +87,7 @@
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
@@ -94,6 +102,10 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Conversation agent",
"error": {
"api_error": "Unable to get model info: {message}",
"model_not_found": "Model not found"
},
"initiate_flow": {
"reconfigure": "Reconfigure conversation agent",
"user": "Add conversation agent"
@@ -103,11 +115,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
"prompt_caching": "Caching strategy",
"temperature": "Temperature"
},
"data_description": {
"chat_model": "The model to serve the responses.",
"max_tokens": "Limit the number of response tokens.",
"prompt_caching": "Optimize your API cost and response times based on your usage.",
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
},
"title": "Advanced settings"
@@ -132,6 +146,7 @@
"code_execution": "Code execution",
"thinking_budget": "Thinking budget",
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
"user_location": "Include home location",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches"
@@ -140,6 +155,7 @@
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
"user_location": "Localize search results based on home location",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response"
@@ -210,6 +226,13 @@
}
},
"selector": {
"prompt_caching": {
"options": {
"automatic": "Full",
"off": "Disabled",
"prompt": "System prompt"
}
},
"thinking_effort": {
"options": {
"high": "[%key:common::state::high%]",

View File

@@ -7,9 +7,9 @@ import logging
from typing import TYPE_CHECKING, Any
from aurorapy.client import AuroraError, AuroraSerialClient
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
@@ -57,9 +57,11 @@ def validate_and_connect(
return ret
def scan_comports() -> tuple[list[str] | None, str | None]:
async def async_scan_comports(
hass: HomeAssistant,
) -> tuple[list[str] | None, str | None]:
"""Find and store available com ports for the GUI dropdown."""
com_ports = serial.tools.list_ports.comports(include_links=True)
com_ports = await usb.async_scan_serial_ports(hass)
com_ports_list = []
for port in com_ports:
com_ports_list.append(port.device)
@@ -87,7 +89,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if self._com_ports_list is None:
result = await self.hass.async_add_executor_job(scan_comports)
result = await async_scan_comports(self.hass)
self._com_ports_list, self._default_com_port = result
if self._default_com_port is None:
return self.async_abort(reason="no_serial_ports")

View File

@@ -3,6 +3,7 @@
"name": "Aurora ABB PowerOne Solar PV",
"codeowners": ["@davet2001"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -8,7 +8,7 @@ from autoskope_client.models import CannotConnect, InvalidAuth
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST
@@ -31,8 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) ->
try:
await api.connect()
except InvalidAuth as err:
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
raise ConfigEntryError(
raise ConfigEntryAuthFailed(
"Authentication failed, please check credentials"
) from err
except CannotConnect as err:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from autoskope_client.api import AutoskopeApi
@@ -39,12 +40,39 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autoskope."""
VERSION = 1
async def _async_validate_credentials(
self, host: str, username: str, password: str, errors: dict[str, str]
) -> bool:
"""Validate credentials against the Autoskope API."""
try:
async with AutoskopeApi(
host=host,
username=username,
password=password,
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return True
return False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -63,18 +91,9 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{username}@{host}")
self._abort_if_unique_id_configured()
try:
async with AutoskopeApi(
host=host,
username=username,
password=user_input[CONF_PASSWORD],
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
if await self._async_validate_credentials(
host, username, user_input[CONF_PASSWORD], errors
):
return self.async_create_entry(
title=f"Autoskope ({username})",
data={
@@ -87,3 +106,35 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle initiation of re-authentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication with new credentials."""
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
if await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
errors,
):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@@ -39,10 +39,7 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: todo
comment: |
Reauthentication flow removed for initial PR, will be added in follow-up.
reauthentication-flow: done
test-coverage: done
# Gold
devices: done

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,6 +11,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The new password for your Autoskope account."
},
"description": "Please re-enter your password for your Autoskope account."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==67"],
"requirements": ["axis==68"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -74,6 +74,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
translation_domain=DOMAIN,
translation_key="invalid_bucket_name",
) from err
except exception.BadRequest as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="bad_request",
translation_placeholders={"error_message": str(err)},
) from err
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,

View File

@@ -101,8 +101,7 @@ def handle_b2_errors[T](
try:
return await func(*args, **kwargs)
except B2Error as err:
error_msg = f"Failed during {func.__name__}"
raise BackupAgentError(error_msg) from err
raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err
return wrapper
@@ -170,8 +169,7 @@ class BackblazeBackupAgent(BackupAgent):
async def _cleanup_failed_upload(self, filename: str) -> None:
"""Clean up a partially uploaded file after upload failure."""
_LOGGER.warning(
"Attempting to delete partially uploaded main backup file %s "
"due to metadata upload failure",
"Attempting to delete partially uploaded backup file %s",
filename,
)
try:
@@ -180,11 +178,10 @@ class BackblazeBackupAgent(BackupAgent):
)
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
except B2Error:
_LOGGER.debug(
"Failed to clean up partially uploaded main backup file %s. "
"Manual intervention may be required to delete it from Backblaze B2",
_LOGGER.warning(
"Failed to clean up partially uploaded backup file %s;"
" manual deletion from Backblaze B2 may be required",
filename,
exc_info=True,
)
else:
_LOGGER.debug(
@@ -256,9 +253,10 @@ class BackblazeBackupAgent(BackupAgent):
prefixed_metadata_filename,
)
upload_successful = False
tar_uploaded = False
try:
await self._upload_backup_file(prefixed_tar_filename, open_stream, {})
tar_uploaded = True
_LOGGER.debug(
"Main backup file upload finished for %s", prefixed_tar_filename
)
@@ -270,15 +268,14 @@ class BackblazeBackupAgent(BackupAgent):
_LOGGER.debug(
"Metadata file upload finished for %s", prefixed_metadata_filename
)
upload_successful = True
finally:
if upload_successful:
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
self._invalidate_caches(
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
)
else:
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
self._invalidate_caches(
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
)
except B2Error:
if tar_uploaded:
await self._cleanup_failed_upload(prefixed_tar_filename)
raise
def _upload_metadata_file_sync(
self, metadata_content: bytes, filename: str

View File

@@ -174,6 +174,14 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
)
errors[CONF_BUCKET] = "invalid_bucket_name"
except exception.BadRequest as err:
_LOGGER.error(
"Backblaze B2 API rejected the request for Key ID '%s': %s",
user_input[CONF_KEY_ID],
err,
)
errors["base"] = "bad_request"
placeholders["error_message"] = str(err)
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["b2sdk"],
"quality_scale": "bronze",
"requirements": ["b2sdk==2.10.1"]
"requirements": ["b2sdk==2.10.4"]
}

View File

@@ -6,6 +6,7 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"bad_request": "The Backblaze B2 API rejected the request: {error_message}",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]",
"invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]",
@@ -60,6 +61,9 @@
}
},
"exceptions": {
"bad_request": {
"message": "The Backblaze B2 API rejected the request: {error_message}"
},
"cannot_connect": {
"message": "Cannot connect to endpoint"
},

View File

@@ -23,7 +23,7 @@ from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import AgentBackup, BackupNotFound
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
@callback
@@ -195,6 +195,11 @@ class UploadBackupView(HomeAssistantView):
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
except InvalidBackupFilename as err:
return Response(
body=str(err),
status=HTTPStatus.BAD_REQUEST,
)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",

View File

@@ -68,6 +68,7 @@ from .models import (
BackupReaderWriterError,
BaseBackup,
Folder,
InvalidBackupFilename,
)
from .store import BackupStore
from .util import (
@@ -1006,6 +1007,14 @@ class BackupManager:
) -> str:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
suggested_filename = contents.filename or "backup.tar"
safe_filename = PureWindowsPath(suggested_filename).name
if (
not safe_filename
or safe_filename != suggested_filename
or safe_filename == ".."
):
raise InvalidBackupFilename(f"Invalid filename: {suggested_filename}")
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
@@ -1016,7 +1025,7 @@ class BackupManager:
written_backup = await self._reader_writer.async_receive_backup(
agent_ids=agent_ids,
stream=contents,
suggested_filename=contents.filename or "backup.tar",
suggested_filename=suggested_filename,
)
self.async_on_backup_event(
ReceiveBackupEvent(
@@ -1957,10 +1966,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
suggested_filename: str,
) -> WrittenBackup:
"""Receive a backup."""
safe_filename = PureWindowsPath(suggested_filename).name
if not safe_filename or safe_filename == "..":
safe_filename = "backup.tar"
temp_file = Path(self.temp_backup_dir, safe_filename)
temp_file = Path(self.temp_backup_dir, suggested_filename)
async_add_executor_job = self._hass.async_add_executor_job
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
"requirements": ["cronsim==2.7", "securetar==2026.4.1"],
"single_config_entry": true
}

View File

@@ -95,6 +95,12 @@ class BackupReaderWriterError(BackupError):
error_code = "backup_reader_writer_error"
class InvalidBackupFilename(BackupManagerError):
"""Raised when a backup filename is invalid."""
error_code = "invalid_backup_filename"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""

View File

@@ -22,6 +22,7 @@ from securetar import (
SecureTarFile,
SecureTarReadError,
SecureTarRootKeyContext,
get_archive_max_ciphertext_size,
)
from homeassistant.core import HomeAssistant
@@ -383,9 +384,12 @@ def _encrypt_backup(
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
output_archive.import_tar(
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
)
if (fileobj := input_tar.extractfile(obj)) is None:
LOGGER.debug(
"Non regular inner tar file %s will not be encrypted", obj.name
)
continue
output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx)
inner_tar_idx += 1
@@ -419,7 +423,7 @@ class _CipherBackupStreamer:
hass: HomeAssistant,
backup: AgentBackup,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
password: str,
) -> None:
"""Initialize."""
self._workers: list[_CipherWorkerStatus] = []
@@ -431,7 +435,9 @@ class _CipherBackupStreamer:
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
return get_archive_max_ciphertext_size(
self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files()
)
def _num_tar_files(self) -> int:
"""Return the number of inner tar files."""

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.11.1"
"dbus-fast==4.0.4",
"habluetooth==6.0.0"
]
}

View File

@@ -1,7 +1,7 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}

View File

@@ -260,6 +260,14 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
),
)
PRESET_BUTTON = BondButtonEntityDescription(
key=Action.PRESET,
name="Preset",
translation_key="preset",
mutually_exclusive=None,
argument=None,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -285,6 +293,8 @@ async def async_setup_entry(
# we only add the stop action button if we add actions
# since its not so useful if there are no actions to stop
device_entities.append(BondButtonEntity(data, device, STOP_BUTTON))
if device.has_action(PRESET_BUTTON.key):
device_entities.append(BondButtonEntity(data, device, PRESET_BUTTON))
entities.extend(device_entities)
async_add_entities(entities)

View File

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"quality_scale": "platinum",
"requirements": ["brother==6.0.0"],
"requirements": ["brother==6.1.0"],
"zeroconf": [
{
"name": "brother*",

View File

@@ -10,6 +10,7 @@ from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
BSBLANError,
HotWaterConfig,
HotWaterSchedule,
HotWaterState,
@@ -50,7 +51,7 @@ class BSBLanFastData:
state: State
sensor: Sensor
dhw: HotWaterState
dhw: HotWaterState | None = None
@dataclass
@@ -111,7 +112,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -126,6 +126,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
translation_placeholders={"host": host},
) from err
# Fetch DHW state separately - device may not support hot water
dhw: HotWaterState | None = None
try:
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANError:
# Preserve last known DHW state if available (entity may depend on it)
if self.data:
dhw = self.data.dhw
LOGGER.debug(
"DHW (Domestic Hot Water) state not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -159,13 +172,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:
# Device does not support DHW functionality
LOGGER.debug(
"DHW (Domestic Hot Water) not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanSlowData()
except (BSBLANConnectionError, BSBLANAuthError) as err:
# If config update fails, keep existing data
LOGGER.debug(
@@ -177,6 +183,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
return self.data
# First fetch failed, return empty data
return BSBLanSlowData()
except BSBLANError, AttributeError:
# Device does not support DHW functionality
LOGGER.debug(
"DHW (Domestic Hot Water) not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanSlowData()
return BSBLanSlowData(
dhw_config=dhw_config,

View File

@@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics(
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump()
if data.fast_coordinator.data.dhw
else None,
},
"static": data.static.model_dump() if data.static is not None else None,
}

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -10,7 +13,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from .const import DOMAIN
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
@@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
"""Initialize BSBLan entity with device info."""
super().__init__(coordinator)
host = coordinator.config_entry.data["host"]
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
mac = data.device.MAC
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
@@ -44,7 +48,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
else None
),
sw_version=data.device.version,
configuration_url=f"http://{host}",
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
)

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.3"],
"requirements": ["python-bsblan==5.1.4"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from bsblan import BSBLANError, SetHotWaterParam
from bsblan import BSBLANError, HotWaterState, SetHotWaterParam
from homeassistant.components.water_heater import (
STATE_ECO,
@@ -46,8 +46,10 @@ async def async_setup_entry(
data = entry.runtime_data
# Only create water heater entity if DHW (Domestic Hot Water) is available
# Check if we have any DHW-related data indicating water heater support
dhw_data = data.fast_coordinator.data.dhw
if dhw_data is None:
# Device does not support DHW, skip water heater setup
return
if (
dhw_data.operating_mode is None
and dhw_data.nominal_setpoint is None
@@ -107,11 +109,21 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
else:
self._attr_max_temp = 65.0 # Default maximum
@property
def _dhw(self) -> HotWaterState:
"""Return DHW state data.
This entity is only created when DHW data is available.
"""
dhw = self.coordinator.data.dhw
assert dhw is not None
return dhw
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if (
operating_mode := self.coordinator.data.dhw.operating_mode
operating_mode := self._dhw.operating_mode
) is None or operating_mode.value is None:
return None
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
@@ -119,16 +131,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
) is None:
if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None:
return None
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
if (target_temp := self._dhw.nominal_setpoint) is None:
return None
return target_temp.value

View File

@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.components import frontend, http, websocket_api
from homeassistant.components.websocket_api import (
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
ERR_NOT_SUPPORTED,
ActiveConnection,
@@ -33,6 +34,7 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_time
@@ -76,6 +78,7 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = datetime.timedelta(seconds=60)
EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds
# Don't support rrules more often than daily
VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
@@ -320,6 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
websocket_api.async_register_command(hass, handle_calendar_event_create)
websocket_api.async_register_command(hass, handle_calendar_event_delete)
websocket_api.async_register_command(hass, handle_calendar_event_update)
websocket_api.async_register_command(hass, handle_calendar_event_subscribe)
component.async_register_entity_service(
CREATE_EVENT_SERVICE,
@@ -517,6 +521,17 @@ class CalendarEntity(Entity):
_entity_component_unrecorded_attributes = frozenset({"description"})
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
_event_listeners: (
list[
tuple[
datetime.datetime,
datetime.datetime,
Callable[[list[JsonValueType] | None], None],
]
]
| None
) = None
_event_listener_debouncer: Debouncer[None] | None = None
_attr_initial_color: str | None
@@ -585,6 +600,10 @@ class CalendarEntity(Entity):
the current or upcoming event.
"""
super()._async_write_ha_state()
# Notify websocket subscribers of event changes (debounced)
if self._event_listeners and self._event_listener_debouncer:
self._event_listener_debouncer.async_schedule_call()
if self._alarm_unsubs is None:
self._alarm_unsubs = []
_LOGGER.debug(
@@ -625,6 +644,13 @@ class CalendarEntity(Entity):
event.end_datetime_local,
)
@callback
def _async_cancel_event_listener_debouncer(self) -> None:
"""Cancel and clear the event listener debouncer."""
if self._event_listener_debouncer:
self._event_listener_debouncer.async_cancel()
self._event_listener_debouncer = None
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
@@ -633,6 +659,90 @@ class CalendarEntity(Entity):
for unsub in self._alarm_unsubs or ():
unsub()
self._alarm_unsubs = None
self._async_cancel_event_listener_debouncer()
@final
@callback
def async_subscribe_events(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
event_listener: Callable[[list[JsonValueType] | None], None],
) -> CALLBACK_TYPE:
"""Subscribe to calendar event updates.
Called by websocket API.
"""
if self._event_listeners is None:
self._event_listeners = []
if self._event_listener_debouncer is None:
self._event_listener_debouncer = Debouncer(
self.hass,
_LOGGER,
cooldown=EVENT_LISTENER_DEBOUNCE_COOLDOWN,
immediate=True,
function=self.async_update_event_listeners,
)
listener_data = (start_date, end_date, event_listener)
self._event_listeners.append(listener_data)
@callback
def unsubscribe() -> None:
if self._event_listeners:
self._event_listeners.remove(listener_data)
if not self._event_listeners:
self._async_cancel_event_listener_debouncer()
return unsubscribe
@final
@callback
def async_update_event_listeners(self) -> None:
"""Push updated calendar events to all listeners."""
if not self._event_listeners:
return
for start_date, end_date, listener in self._event_listeners:
self.async_update_single_event_listener(start_date, end_date, listener)
@final
@callback
def async_update_single_event_listener(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
listener: Callable[[list[JsonValueType] | None], None],
) -> None:
"""Schedule an event fetch and push to a single listener."""
self.hass.async_create_task(
self._async_update_listener(start_date, end_date, listener)
)
async def _async_update_listener(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
listener: Callable[[list[JsonValueType] | None], None],
) -> None:
"""Fetch events and push to a single listener."""
try:
events = await self.async_get_events(self.hass, start_date, end_date)
except HomeAssistantError as err:
_LOGGER.debug(
"Error fetching calendar events for %s: %s",
self.entity_id,
err,
)
listener(None)
return
event_list: list[JsonValueType] = [
dataclasses.asdict(event, dict_factory=_list_events_dict_factory)
for event in events
]
listener(event_list)
async def async_get_events(
self,
@@ -867,6 +977,65 @@ async def handle_calendar_event_update(
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "calendar/event/subscribe",
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
vol.Required("start"): cv.datetime,
vol.Required("end"): cv.datetime,
}
)
@websocket_api.async_response
async def handle_calendar_event_subscribe(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Subscribe to calendar event updates."""
entity_id: str = msg["entity_id"]
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
connection.send_error(
msg["id"],
ERR_NOT_FOUND,
f"Calendar entity not found: {entity_id}",
)
return
start_date = dt_util.as_local(msg["start"])
end_date = dt_util.as_local(msg["end"])
if start_date >= end_date:
connection.send_error(
msg["id"],
ERR_INVALID_FORMAT,
"Start must be before end",
)
return
subscription_id = msg["id"]
@callback
def event_listener(events: list[JsonValueType] | None) -> None:
"""Push updated calendar events to websocket."""
if subscription_id not in connection.subscriptions:
return
connection.send_message(
websocket_api.event_message(
subscription_id,
{
"events": events,
},
)
)
connection.subscriptions[subscription_id] = entity.async_subscribe_events(
start_date, end_date, event_listener
)
connection.send_result(subscription_id)
# Push initial events only to the new subscriber
entity.async_update_single_event_listener(start_date, end_date, event_listener)
def _validate_timespan(
values: dict[str, Any],
) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]:

View File

@@ -51,18 +51,24 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
dynamic-devices:
status: exempt
comment: Each config entry represents a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow:
status: exempt
comment: No user-configurable settings in the configuration flow.
repair-issues:
status: exempt
comment: Integration does not register repair issues.
stale-devices: todo
stale-devices:
status: exempt
comment: Each config entry represents a single device.
# Platinum
async-dependency: done

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from datetime import datetime, timedelta
from pycasperglow import GlowState
from homeassistant.components.sensor import (
@@ -13,6 +15,8 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
@@ -26,7 +30,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform for Casper Glow."""
async_add_entities([CasperGlowBatterySensor(entry.runtime_data)])
async_add_entities(
[
CasperGlowBatterySensor(entry.runtime_data),
CasperGlowDimmingEndTimeSensor(entry.runtime_data),
]
)
class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
@@ -59,3 +68,67 @@ class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
if new_value != self._attr_native_value:
self._attr_native_value = new_value
self.async_write_ha_state()
class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity):
"""Sensor entity for Casper Glow dimming end time."""
_attr_translation_key = "dimming_end_time"
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_entity_registry_enabled_default = False
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming end time sensor."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{format_mac(coordinator.device.address)}_dimming_end_time"
)
self._is_paused = False
self._projected_end_time = ignore_variance(
self._calculate_end_time,
timedelta(minutes=1, seconds=30),
)
self._update_from_state(coordinator.device.state)
@staticmethod
def _calculate_end_time(remaining_ms: int) -> datetime:
"""Calculate projected dimming end time from remaining milliseconds."""
return utcnow() + timedelta(milliseconds=remaining_ms)
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
def _reset_projected_end_time(self) -> None:
"""Clear the projected end time and reset the variance filter."""
self._attr_native_value = None
self._projected_end_time = ignore_variance(
self._calculate_end_time,
timedelta(minutes=1, seconds=30),
)
@callback
def _update_from_state(self, state: GlowState) -> None:
"""Update entity attributes from device state."""
if state.is_paused is not None:
self._is_paused = state.is_paused
if self._is_paused:
self._reset_projected_end_time()
return
remaining_ms = state.dimming_time_remaining_ms
if not remaining_ms:
if remaining_ms == 0 or state.is_on is False:
self._reset_projected_end_time()
return
self._attr_native_value = self._projected_end_time(remaining_ms)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
self._update_from_state(state)
self.async_write_ha_state()

View File

@@ -44,6 +44,11 @@
"dimming_time": {
"name": "Dimming time"
}
},
"sensor": {
"dimming_end_time": {
"name": "Dimming end time"
}
}
},
"exceptions": {

View File

@@ -44,10 +44,10 @@
},
"services": {
"show_lovelace_view": {
"description": "Shows a dashboard view on a Chromecast device.",
"description": "Shows a dashboard view on a Google Cast device.",
"fields": {
"dashboard_path": {
"description": "The URL path of the dashboard to show, defaults to lovelace if not specified.",
"description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.",
"name": "Dashboard path"
},
"entity_id": {
@@ -59,7 +59,7 @@
"name": "View path"
}
},
"name": "Show dashboard view"
"name": "Show dashboard view via Google Cast"
}
}
}

View File

@@ -39,7 +39,9 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
else:
await self.async_set_unique_id(str(user.player_id))
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user.name, data=user_input)
return self.async_create_entry(
title=user.name or user.username, data=user_input
)
return self.async_show_form(
step_id="user",

View File

@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
if self._area.human_status == AlarmAreaState.UNKNOWN:
return False
return super().available
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
if code != str(self.coordinator.api.device_pin):
return
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE]
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[AWAY]
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[HOME]
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[NIGHT]
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]

View File

@@ -1,15 +1,18 @@
"""Support for sensors."""
"""Support for binary sensors."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, cast
from aiocomelit.api import ComelitVedoZoneObject
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject
from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,12 +26,68 @@ from .utils import new_device_listener
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ComelitBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Comelit binary sensor entity description."""
object_type: str
is_on_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool]
available_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] = (
lambda obj: True
)
BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = (
ComelitBinarySensorEntityDescription(
key="anomaly",
translation_key="anomaly",
object_type=ALARM_AREA,
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly,
available_fn=lambda obj: (
cast(ComelitVedoAreaObject, obj).human_status != AlarmAreaState.UNKNOWN
),
),
ComelitBinarySensorEntityDescription(
key="presence",
translation_key="motion",
object_type=ALARM_ZONE,
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda obj: cast(ComelitVedoZoneObject, obj).status_api == "0001",
available_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status
not in {
AlarmZoneState.FAULTY,
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
}
),
),
ComelitBinarySensorEntityDescription(
key="faulty",
translation_key="faulty",
object_type=ALARM_ZONE,
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status == AlarmZoneState.FAULTY
),
available_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status
not in {
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
}
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit VEDO presence sensors."""
"""Set up Comelit VEDO binary sensors."""
coordinator = config_entry.runtime_data
is_bridge = isinstance(coordinator, ComelitSerialBridge)
@@ -42,13 +101,23 @@ async def async_setup_entry(
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
ComelitVedoBinarySensorEntity(
coordinator,
device,
config_entry.entry_id,
description,
)
for description in BINARY_SENSOR_TYPES
for device in coordinator.data[dev_type].values()
if description.object_type == dev_type
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, ALARM_AREA)
)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
)
@@ -59,42 +128,47 @@ class ComelitVedoBinarySensorEntity(
):
"""Sensor device."""
entity_description: ComelitBinarySensorEntityDescription
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(
self,
coordinator: ComelitVedoSystem | ComelitSerialBridge,
zone: ComelitVedoZoneObject,
object_data: ComelitVedoAreaObject | ComelitVedoZoneObject,
config_entry_entry_id: str,
description: ComelitBinarySensorEntityDescription,
) -> None:
"""Init sensor entity."""
self._zone_index = zone.index
self.entity_description = description
self._object_index = object_data.index
self._object_type = description.object_type
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self._attr_unique_id = (
f"{config_entry_entry_id}-{description.key}-{self._object_index}"
)
self._attr_device_info = coordinator.platform_device_info(
object_data, "area" if self._object_type == ALARM_AREA else "zone"
)
@property
def _zone(self) -> ComelitVedoZoneObject:
"""Return zone object."""
def _object(self) -> ComelitVedoAreaObject | ComelitVedoZoneObject:
"""Return alarm object."""
return cast(
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
ComelitVedoAreaObject | ComelitVedoZoneObject,
self.coordinator.data[self._object_type][self._object_index],
)
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._zone.human_status in [
AlarmZoneState.FAULTY,
AlarmZoneState.UNAVAILABLE,
AlarmZoneState.UNKNOWN,
]:
"""Return True if object is available."""
if not self.entity_description.available_fn(self._object):
return False
return super().available
@property
def is_on(self) -> bool:
"""Presence detected."""
return self._zone.status_api == "0001"
"""Return object binary sensor state."""
return self.entity_description.is_on_fn(self._object)

View File

@@ -65,6 +65,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
configuration_url=self.api.base_url,
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
model=device,

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.1"]
"requirements": ["aiocomelit==2.0.2"]
}

View File

@@ -64,6 +64,17 @@
}
},
"entity": {
"binary_sensor": {
"anomaly": {
"name": "Anomaly"
},
"faulty": {
"name": "Faulty"
},
"motion": {
"name": "Motion"
}
},
"climate": {
"thermostat": {
"state_attributes": {

View File

@@ -10,8 +10,6 @@ from crownstone_cloud.exceptions import (
CrownstoneAuthenticationError,
CrownstoneUnknownError,
)
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant.components import usb
@@ -61,9 +59,11 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up a Crownstone USB dongle."""
list_of_ports = await self.hass.async_add_executor_job(
serial.tools.list_ports.comports
)
list_of_ports = [
p
for p in await usb.async_scan_serial_ports(self.hass)
if isinstance(p, usb.USBDevice)
]
if self.flow_type == CONFIG_FLOW:
ports_as_string = list_ports_as_str(list_of_ports)
else:
@@ -82,10 +82,8 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
else:
index = ports_as_string.index(selection) - 1
selected_port: ListPortInfo = list_of_ports[index]
self.usb_path = await self.hass.async_add_executor_job(
usb.get_serial_by_id, selected_port.device
)
selected_port = list_of_ports[index]
self.usb_path = selected_port.device
return await self.async_step_usb_sphere_config()
return self.async_show_form(

View File

@@ -5,15 +5,14 @@ from __future__ import annotations
from collections.abc import Sequence
import os
from serial.tools.list_ports_common import ListPortInfo
from homeassistant.components import usb
from homeassistant.components.usb import USBDevice
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
def list_ports_as_str(
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
serial_ports: Sequence[USBDevice], no_usb_option: bool = True
) -> list[str]:
"""Represent currently available serial ports as string.
@@ -31,8 +30,8 @@ def list_ports_as_str(
port.serial_number,
port.manufacturer,
port.description,
f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None,
f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None,
port.vid,
port.pid,
)
for port in serial_ports
)

View File

@@ -1,9 +1,9 @@
{
"domain": "crownstone",
"name": "Crownstone",
"after_dependencies": ["usb"],
"codeowners": ["@Crownstone", "@RicArch97"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/crownstone",
"iot_class": "cloud_push",
"loggers": [
@@ -15,7 +15,6 @@
"requirements": [
"crownstone-cloud==1.4.11",
"crownstone-sse==2.0.5",
"crownstone-uart==2.1.0",
"pyserial==3.5"
"crownstone-uart==2.1.0"
]
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from functools import partial
import os
from typing import Any
from dsmr_parser import obis_references as obis_ref
@@ -15,9 +14,9 @@ from dsmr_parser.clients.rfxtrx_protocol import (
)
from dsmr_parser.objects import DSMRObject
import serial
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -229,9 +228,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = await self.hass.async_add_executor_job(
get_serial_by_id, user_selection
)
dev_path = user_selection
validate_data = {
CONF_PORT: dev_path,
@@ -242,9 +239,10 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
ports = await usb.async_scan_serial_ports(self.hass)
list_of_ports = {
port.device: f"{port}, s/n: {port.serial_number or 'n/a'}"
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
@@ -335,18 +333,6 @@ class DSMROptionFlowHandler(OptionsFlow):
)
def get_serial_by_id(dev_path: str) -> str:
"""Return a /dev/serial/by-id match for given device if available."""
by_id = "/dev/serial/by-id"
if not os.path.isdir(by_id):
return dev_path
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
if os.path.realpath(path) == dev_path:
return path
return dev_path
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -3,6 +3,7 @@
"name": "DSMR Smart Meter",
"codeowners": ["@Robbie1221"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/dsmr",
"integration_type": "hub",
"iot_class": "local_push",

View File

@@ -0,0 +1,34 @@
"""The Duco integration."""
from __future__ import annotations
from duco import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
)
coordinator = DucoCoordinator(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: DucoConfigEntry) -> bool:
"""Unload a Duco config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,74 @@
"""Config flow for the Duco integration."""
from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Duco."""
VERSION = 1
MINOR_VERSION = 1
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:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
_LOGGER.exception("Unexpected error connecting to Duco box")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=box_name,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
)
async def _validate_input(self, host: str) -> tuple[str, str]:
"""Validate the user input by connecting to the Duco box.
Returns a tuple of (box_name, mac_address).
"""
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac

View File

@@ -0,0 +1,9 @@
"""Constants for the Duco integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN]
SCAN_INTERVAL = timedelta(seconds=30)

View File

@@ -0,0 +1,75 @@
"""Data update coordinator for the Duco integration."""
from __future__ import annotations
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
type DucoData = dict[int, Node]
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Coordinator for the Duco integration."""
config_entry: DucoConfigEntry
board_info: BoardInfo
def __init__(
self,
hass: HomeAssistant,
config_entry: DucoConfigEntry,
client: DucoClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await self.client.async_get_board_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(f"Duco API error: {err}") from err
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
return {node.node_id: node for node in nodes}

View File

@@ -0,0 +1,52 @@
"""Base entity for the Duco integration."""
from __future__ import annotations
from duco.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import DucoCoordinator
class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"""Base class for Duco entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._node_id = node.node_id
mac = coordinator.config_entry.unique_id
assert mac is not None
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
device_info.update(
{
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._node_id in self.coordinator.data
@property
def _node(self) -> Node:
"""Return the current node data from the coordinator."""
return self.coordinator.data[self._node_id]

View File

@@ -0,0 +1,127 @@
"""Fan platform for the Duco integration."""
from __future__ import annotations
from duco.exceptions import DucoError
from duco.models import Node, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
PARALLEL_UPDATES = 1
# Permanent speed states ordered low → high.
ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [
VentilationState.CNT1,
VentilationState.CNT2,
VentilationState.CNT3,
]
PRESET_AUTO = "auto"
# Upper-bound percentages for 3 speed levels: 33 / 66 / 100.
# Using upper bounds guarantees that reading a percentage back and writing it
# again always round-trips to the same Duco state.
_SPEED_LEVEL_PERCENTAGES: list[int] = [
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
]
# Maps every active Duco state (including timed MAN variants) to its
# display percentage so externally-set timed modes show the correct level.
_STATE_TO_PERCENTAGE: dict[VentilationState, int] = {
VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2],
}
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco fan entities."""
coordinator = entry.runtime_data
async_add_entities(
DucoVentilationFanEntity(coordinator, node)
for node in coordinator.data.values()
if node.general.node_type == "BOX"
)
class DucoVentilationFanEntity(DucoEntity, FanEntity):
"""Fan entity for the ventilation control of a Duco node."""
_attr_translation_key = "ventilation"
_attr_name = None
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
_attr_preset_modes = [PRESET_AUTO]
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the fan entity."""
super().__init__(coordinator, node)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}"
@property
def percentage(self) -> int | None:
"""Return the current speed as a percentage, or None when in AUTO mode."""
node = self._node
if node.ventilation is None:
return None
return _STATE_TO_PERCENTAGE.get(node.ventilation.state)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode (auto when Duco controls, else None)."""
node = self._node
if node.ventilation is None:
return None
if node.ventilation.state not in _STATE_TO_PERCENTAGE:
return PRESET_AUTO
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode: 'auto' hands control back to Duco."""
self._valid_preset_mode_or_raise(preset_mode)
await self._async_set_state(VentilationState.AUTO)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the fan speed as a percentage (maps to low/medium/high)."""
if percentage == 0:
await self._async_set_state(VentilationState.AUTO)
return
state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
await self._async_set_state(state)
async def _async_set_state(self, state: VentilationState) -> None:
"""Send the ventilation state to the device and refresh coordinator."""
try:
await self.coordinator.client.async_set_ventilation_state(
self._node_id, state
)
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
translation_placeholders={"error": repr(err)},
) from err
await self.coordinator.async_refresh()

View File

@@ -0,0 +1,12 @@
{
"domain": "duco",
"name": "Duco",
"codeowners": ["@ronaldvdmeer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "bronze",
"requirements": ["python-duco-client==0.3.0"]
}

View File

@@ -0,0 +1,89 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not provide service 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 provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration uses a coordinator; entities do not subscribe to events directly.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not provide an option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: Handled by the DataUpdateCoordinator.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration uses a local API that requires no credentials.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: >-
DHCP host updating to be implemented in a follow-up PR.
The device hostname follows the pattern duco_<last 6 chars of MAC>
(e.g. duco_061293), which can be used for DHCP hostname matching.
discovery:
status: todo
comment: >-
Device can be discovered via DHCP. The hostname follows the pattern
duco_<last 6 chars of MAC> (e.g. duco_061293). To be implemented
in a follow-up PR together with discovery-update-info.
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: todo
comment: >-
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
to their Duco box. Dynamic device support to be added in a follow-up PR.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: >-
To be implemented together with dynamic device support in a follow-up PR.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "IP address or hostname of your Duco ventilation box."
}
}
}
},
"entity": {
"fan": {
"ventilation": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]"
}
}
}
}
}
},
"exceptions": {
"api_error": {
"message": "Unexpected error from the Duco API: {error}"
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Duco instance: {error}"
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
}
}
}

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from elgato import Elgato, ElgatoError
from elgato import Elgato
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -15,11 +15,11 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
PARALLEL_UPDATES = 1
@@ -80,11 +80,7 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity):
f"{coordinator.data.info.serial_number}_{description.key}"
)
@elgato_exception_handler
async def async_press(self) -> None:
"""Trigger button press on the Elgato device."""
try:
await self.entity_description.press_fn(self.coordinator.client)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while communicating with the Elgato Light"
) from error
await self.entity_description.press_fn(self.coordinator.client)

View File

@@ -12,6 +12,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -23,7 +25,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
host: str
port: int
serial_number: str
mac: str | None = None
@@ -70,6 +71,32 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initiated by zeroconf."""
return self._async_create_entry()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of a known Elgato device.
Only devices already configured (matched via ``registered_devices``)
reach this step. It is used to keep the stored host in sync with the
current IP address of the device.
"""
mac = format_mac(discovery_info.macaddress)
for entry in self._async_current_entries():
if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac(
entry_mac
) != mac:
continue
if entry.data[CONF_HOST] != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data=entry.data | {CONF_HOST: discovery_info.ip},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
return self.async_abort(reason="no_devices_found")
@callback
def _async_show_setup_form(
self, errors: dict[str, str] | None = None

View File

@@ -2,7 +2,15 @@
from dataclasses import dataclass
from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State
from elgato import (
BatteryInfo,
Elgato,
ElgatoConnectionError,
ElgatoError,
Info,
Settings,
State,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -59,4 +67,12 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]):
state=await self.client.state(),
)
except ElgatoConnectionError as err:
raise UpdateFailed(err) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
except ElgatoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown_error",
) from err

View File

@@ -0,0 +1,43 @@
"""Helpers for Elgato."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from elgato import ElgatoConnectionError, ElgatoError
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .entity import ElgatoEntity
def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P](
func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Elgato calls to handle Elgato exceptions.
A decorator that wraps the passed in function, catches Elgato errors,
and raises a translated ``HomeAssistantError``.
"""
async def handler(
self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except ElgatoConnectionError as error:
self.coordinator.last_update_success = False
self.coordinator.async_update_listeners()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
except ElgatoError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
) from error
return handler

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
from typing import Any
from elgato import ElgatoError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -14,12 +12,12 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
PARALLEL_UPDATES = 1
@@ -94,17 +92,13 @@ class ElgatoLight(ElgatoEntity, LightEntity):
"""Return the state of the light."""
return self.coordinator.data.state.on
@elgato_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
try:
await self.coordinator.client.light(on=False)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
await self.coordinator.client.light(on=False)
await self.coordinator.async_request_refresh()
@elgato_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
@@ -137,26 +131,16 @@ class ElgatoLight(ElgatoEntity, LightEntity):
else color_util.color_temperature_kelvin_to_mired(temperature_kelvin)
)
try:
await self.coordinator.client.light(
on=True,
brightness=brightness,
hue=hue,
saturation=saturation,
temperature=temperature,
)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
await self.coordinator.client.light(
on=True,
brightness=brightness,
hue=hue,
saturation=saturation,
temperature=temperature,
)
await self.coordinator.async_request_refresh()
@elgato_exception_handler
async def async_identify(self) -> None:
"""Identify the light, will make it blink."""
try:
await self.coordinator.client.identify()
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while identifying the Elgato Light"
) from error
await self.coordinator.client.identify()

View File

@@ -3,6 +3,11 @@
"name": "Elgato Light",
"codeowners": ["@frenck"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
}
],
"documentation": "https://www.home-assistant.io/integrations/elgato",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -39,11 +39,7 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: todo
comment: |
The integration doesn't update the device info based on DHCP discovery
of known existing devices.
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
@@ -64,7 +60,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
@@ -48,6 +49,14 @@
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Elgato device."
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the Elgato device."
}
},
"services": {
"identify": {
"description": "Identifies an Elgato Light. Blinks the light, which can be useful for, e.g., a visual notification.",

View File

@@ -6,16 +6,16 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from elgato import Elgato, ElgatoError
from elgato import Elgato
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
from .helpers import elgato_exception_handler
PARALLEL_UPDATES = 1
@@ -92,24 +92,14 @@ class ElgatoSwitchEntity(ElgatoEntity, SwitchEntity):
"""Return state of the switch."""
return self.entity_description.is_on_fn(self.coordinator.data)
@elgato_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
try:
await self.entity_description.set_fn(self.coordinator.client, True)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
await self.entity_description.set_fn(self.coordinator.client, True)
await self.coordinator.async_request_refresh()
@elgato_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
try:
await self.entity_description.set_fn(self.coordinator.client, False)
except ElgatoError as error:
raise HomeAssistantError(
"An error occurred while updating the Elgato Light"
) from error
finally:
await self.coordinator.async_refresh()
await self.entity_description.set_fn(self.coordinator.client, False)
await self.coordinator.async_request_refresh()

View File

@@ -6,25 +6,17 @@ rules:
appropriate-polling:
status: exempt
comment: The integration uses a push-based mechanism with a background sync task, not polling.
brands:
status: done
common-modules:
status: done
config-flow-test-coverage:
status: done
config-flow:
status: done
dependency-transparency:
status: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not expose any custom service actions.
docs-high-level-description:
status: done
docs-installation-instructions:
status: done
docs-removal-instructions:
status: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not create its own entities.
@@ -34,40 +26,30 @@ rules:
has-entity-name:
status: exempt
comment: This integration does not create its own entities.
runtime-data:
status: done
test-before-configure:
status: done
test-before-setup:
status: done
unique-config-entry:
status: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration does not expose any custom service actions.
config-entry-unloading:
status: done
docs-configuration-parameters:
status: done
docs-installation-parameters:
status: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: This integration does not create its own entities.
integration-owner:
status: done
integration-owner: done
log-when-unavailable:
status: done
comment: The integration logs a single message when the EnergyID service is unavailable.
parallel-updates:
status: exempt
comment: This integration does not create its own entities.
reauthentication-flow:
status: done
test-coverage:
status: done
reauthentication-flow: done
test-coverage: done
# Gold
devices:
@@ -82,21 +64,15 @@ rules:
discovery-update-info:
status: exempt
comment: No discovery mechanism is used.
docs-data-update:
status: done
docs-examples:
status: done
docs-known-limitations:
status: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: This is a service integration not tied to specific device models.
docs-supported-functions:
status: done
docs-troubleshooting:
status: done
docs-use-cases:
status: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: The integration creates a single device entry for the service connection.
@@ -112,8 +88,7 @@ rules:
entity-translations:
status: exempt
comment: This integration does not create its own entities.
exception-translations:
status: done
exception-translations: done
icon-translations:
status: exempt
comment: This integration does not create its own entities.
@@ -128,10 +103,8 @@ rules:
comment: Creates a single service device entry tied to the config entry.
# Platinum
async-dependency:
status: done
inject-websession:
status: done
async-dependency: done
inject-websession: done
strict-typing:
status: todo
comment: Full strict typing compliance will be addressed in a future update.

View File

@@ -259,15 +259,18 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
# Do not use kelvin_to_mired here to prevent precision loss
color_temp_mired = 1_000_000.0 / color_temp_k
data["color_temperature"] = color_temp_mired
if color_temp_modes := _filter_color_modes(
color_modes, LightColorCapability.COLOR_TEMPERATURE
):
data["color_temperature"] = color_temp_mired
color_modes = color_temp_modes
else:
# Convert color temperature to explicit cold/warm white
# values to avoid ESPHome applying brightness to both
# master brightness and white channels (b² effect).
# Also send explicit cold/warm white values to avoid
# ESPHome applying brightness to both master brightness
# and white channels (b² effect). The firmware skips
# deriving cwww from color_temperature when the channels
# are already set explicitly, but still stores
# color_temperature so HA can read it back.
data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm(
color_temp_mired
)

View File

@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.6.2",
"aioesphomeapi==44.13.3",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.1"
"bleak-esphome==3.7.3"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -0,0 +1,80 @@
"""Comet Blue Bluetooth integration."""
from __future__ import annotations
from bleak.exc import BleakError
from eurotronic_cometblue_ha import AsyncCometBlue
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) -> bool:
"""Set up Eurotronic Comet Blue from a config entry."""
address = entry.data[CONF_ADDRESS]
ble_device = async_ble_device_from_address(hass, entry.data[CONF_ADDRESS])
if not ble_device:
raise ConfigEntryNotReady(
f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}"
)
cometblue_device = AsyncCometBlue(
device=ble_device,
pin=int(entry.data[CONF_PIN]),
)
try:
async with cometblue_device:
ble_device_info = await cometblue_device.get_device_info_async()
try:
# Device only returns battery level if PIN is correct
await cometblue_device.get_battery_async()
except TimeoutError as ex:
# This likely means PIN was incorrect on Linux and ESPHome backends
raise ConfigEntryError(
"Failed to read battery level, likely due to incorrect PIN"
) from ex
except BleakError as ex:
raise ConfigEntryNotReady(
f"Failed to get device info from '{cometblue_device.device.address}'"
) from ex
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, address)},
name=f"{ble_device_info['model']} {cometblue_device.device.address}",
manufacturer=ble_device_info["manufacturer"],
model=ble_device_info["model"],
sw_version=ble_device_info["version"],
)
coordinator = CometBlueDataUpdateCoordinator(
hass,
entry,
cometblue_device,
)
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: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,185 @@
"""Comet Blue climate integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
from .entity import CometBlueBluetoothEntity
PARALLEL_UPDATES = 1
MIN_TEMP = 7.5
MAX_TEMP = 28.5
async def async_setup_entry(
hass: HomeAssistant,
entry: CometBlueConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the client entities."""
coordinator = entry.runtime_data
async_add_entities([CometBlueClimateEntity(coordinator)])
class CometBlueClimateEntity(CometBlueBluetoothEntity, ClimateEntity):
"""A Comet Blue Climate climate entity."""
_attr_min_temp = MIN_TEMP
_attr_max_temp = MAX_TEMP
_attr_name = None
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF]
_attr_preset_modes = [
PRESET_COMFORT,
PRESET_ECO,
PRESET_BOOST,
PRESET_AWAY,
PRESET_NONE,
]
_attr_supported_features: ClimateEntityFeature = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_target_temperature_step = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None:
"""Initialize CometBlueClimateEntity."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.address
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.coordinator.data.temperatures["currentTemp"]
@property
def target_temperature(self) -> float | None:
"""Return the temperature currently set to be reached."""
return self.coordinator.data.temperatures["manualTemp"]
@property
def target_temperature_high(self) -> float | None:
"""Return the upper bound target temperature."""
return self.coordinator.data.temperatures["targetTempHigh"]
@property
def target_temperature_low(self) -> float | None:
"""Return the lower bound target temperature."""
return self.coordinator.data.temperatures["targetTempLow"]
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation mode."""
if self.target_temperature == MIN_TEMP:
return HVACMode.OFF
if self.target_temperature == MAX_TEMP:
return HVACMode.HEAT
return HVACMode.AUTO
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
# presets have an order in which they are displayed on TRV:
# away, boost, comfort, eco, none (manual)
if (
self.coordinator.data.holiday.get("start") is None
and self.coordinator.data.holiday.get("end") is not None
and self.target_temperature
== self.coordinator.data.holiday.get("temperature")
):
return PRESET_AWAY
if self.target_temperature == MAX_TEMP:
return PRESET_BOOST
if self.target_temperature == self.target_temperature_high:
return PRESET_COMFORT
if self.target_temperature == self.target_temperature_low:
return PRESET_ECO
return PRESET_NONE
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if self.preset_mode == PRESET_AWAY:
raise ServiceValidationError(
"Cannot adjust TRV remotely, manually disable 'holiday' mode on TRV first"
)
await self.coordinator.send_command(
self.coordinator.device.set_temperature_async,
{
"values": {
# manual temperature always needs to be set, otherwise TRV will turn OFF
"manualTemp": kwargs.get(ATTR_TEMPERATURE)
or self.target_temperature,
# other temperatures can be left unchanged by setting them to None
"targetTempLow": kwargs.get(ATTR_TARGET_TEMP_LOW),
"targetTempHigh": kwargs.get(ATTR_TARGET_TEMP_HIGH),
}
},
)
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
if self.preset_modes and preset_mode not in self.preset_modes:
raise ServiceValidationError(f"Unsupported preset_mode '{preset_mode}'")
if preset_mode in [PRESET_NONE, PRESET_AWAY]:
raise ServiceValidationError(
f"Unable to set preset '{preset_mode}', display only."
)
if preset_mode == PRESET_ECO:
return await self.async_set_temperature(
temperature=self.target_temperature_low
)
if preset_mode == PRESET_COMFORT:
return await self.async_set_temperature(
temperature=self.target_temperature_high
)
if preset_mode == PRESET_BOOST:
return await self.async_set_temperature(temperature=MAX_TEMP)
return None
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF:
return await self.async_set_temperature(temperature=MIN_TEMP)
if hvac_mode == HVACMode.HEAT:
return await self.async_set_temperature(temperature=MAX_TEMP)
if hvac_mode == HVACMode.AUTO:
return await self.async_set_temperature(
temperature=self.target_temperature_low
)
raise ServiceValidationError(f"Unknown HVAC mode '{hvac_mode}'")
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self.async_set_hvac_mode(HVACMode.AUTO)
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)

View File

@@ -0,0 +1,186 @@
"""Config flow for CometBlue."""
from __future__ import annotations
import logging
from typing import Any
from bleak.exc import BleakError
from eurotronic_cometblue_ha import AsyncCometBlue
from eurotronic_cometblue_ha.const import SERVICE
from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol
from homeassistant.components.bluetooth import (
async_ble_device_from_address,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_PIN
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PIN, default="000000"): vol.All(
TextSelector(TextSelectorConfig(type=TextSelectorType.NUMBER)),
vol.Length(min=6, max=6),
),
}
)
def name_from_discovery(discovery: BluetoothServiceInfoBleak | None) -> str:
"""Get the name from a discovery."""
if discovery is None:
return "Comet Blue"
if discovery.name == str(discovery.address):
return discovery.address
return f"{discovery.name} {discovery.address}"
class CometBlueConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for CometBlue."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def _try_connect(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Verify connection to the device with the provided PIN and read initial data."""
device_address = self._discovery_info.address if self._discovery_info else ""
try:
ble_device = async_ble_device_from_address(self.hass, device_address)
LOGGER.info("Testing connection for device at address %s", device_address)
if not ble_device:
return {"base": "cannot_connect"}
cometblue_device = AsyncCometBlue(
device=ble_device,
pin=int(user_input[CONF_PIN]),
)
async with cometblue_device:
try:
# Device only returns battery level if PIN is correct
await cometblue_device.get_battery_async()
except TimeoutError:
# This likely means PIN was incorrect on Linux and ESPHome backends
LOGGER.debug(
"Failed to read battery level, likely due to incorrect PIN",
exc_info=True,
)
return {"base": "invalid_pin"}
except TimeoutError:
LOGGER.debug("Connection to device timed out", exc_info=True)
return {"base": "timeout_connect"}
except BleakError:
LOGGER.debug("Failed to connect to device", exc_info=True)
return {"base": "cannot_connect"}
except Exception: # noqa: BLE001
LOGGER.debug("Unknown error", exc_info=True)
return {"base": "unknown"}
return {}
def _create_entry(
self,
pin: str,
) -> ConfigFlowResult:
"""Create an entry for a discovered device."""
entry_data = {
CONF_ADDRESS: self._discovery_info.address
if self._discovery_info
else None,
CONF_PIN: pin,
}
return self.async_create_entry(
title=name_from_discovery(self._discovery_info), data=entry_data
)
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await self._try_connect(user_input)
if not errors:
return self._create_entry(user_input[CONF_PIN])
return self.async_show_form(
step_id="bluetooth_confirm",
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a flow initialized by Bluetooth discovery."""
address = discovery_info.address
await self.async_set_unique_id(format_mac(address))
self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address})
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": name_from_discovery(self._discovery_info)
}
return await self.async_step_bluetooth_confirm()
async def async_step_pick_device(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the step to pick discovered device."""
current_addresses = self._async_current_ids()
self._discovered_devices = {
discovery_info.address: discovery_info
for discovery_info in async_discovered_service_info(
self.hass, connectable=True
)
if SERVICE in discovery_info.service_uuids
and discovery_info.address not in current_addresses
}
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(format_mac(address))
self._abort_if_unique_id_configured()
self._discovery_info = self._discovered_devices.get(address)
return await self.async_step_bluetooth_confirm()
# Check if there is at least one device
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="pick_device",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(list(self._discovered_devices))}
),
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
return await self.async_step_pick_device()

View File

@@ -0,0 +1,7 @@
"""Constants for Cometblue BLE thermostats."""
from typing import Final
DOMAIN: Final = "eurotronic_cometblue"
MAX_RETRIES: Final = 3

View File

@@ -0,0 +1,132 @@
"""Provides the DataUpdateCoordinator for Comet Blue."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from typing import Any
from bleak.exc import BleakError
from eurotronic_cometblue_ha import AsyncCometBlue, InvalidByteValueError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import MAX_RETRIES
SCAN_INTERVAL = timedelta(minutes=5)
LOGGER = logging.getLogger(__name__)
COMMAND_RETRY_INTERVAL = 2.5
type CometBlueConfigEntry = ConfigEntry[CometBlueDataUpdateCoordinator]
@dataclass
class CometBlueCoordinatorData:
"""Data stored by the coordinator."""
temperatures: dict[str, float | int] = field(default_factory=dict)
holiday: dict = field(default_factory=dict)
class CometBlueDataUpdateCoordinator(DataUpdateCoordinator[CometBlueCoordinatorData]):
"""Class to manage fetching data."""
def __init__(
self,
hass: HomeAssistant,
entry: CometBlueConfigEntry,
cometblue: AsyncCometBlue,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass=hass,
config_entry=entry,
logger=LOGGER,
name=f"Comet Blue {cometblue.client.address}",
update_interval=SCAN_INTERVAL,
)
self.device = cometblue
self.address = cometblue.client.address
async def send_command(
self,
function: Callable[..., Awaitable[dict[str, Any] | None]],
payload: dict[str, Any],
) -> dict[str, Any] | None:
"""Send command to device."""
LOGGER.debug("Updating device %s with '%s'", self.name, payload)
retry_count = 0
while retry_count < MAX_RETRIES:
try:
async with self.device:
return await function(**payload)
except (InvalidByteValueError, TimeoutError, BleakError) as ex:
retry_count += 1
if retry_count >= MAX_RETRIES:
raise HomeAssistantError(
f"Error sending command to '{self.name}': {ex}"
) from ex
LOGGER.info(
"Retry sending command to %s after %s (%s)",
self.name,
type(ex).__name__,
ex,
)
await asyncio.sleep(COMMAND_RETRY_INTERVAL)
except ValueError as ex:
raise ServiceValidationError(
f"Invalid payload '{payload}' for '{self.name}': {ex}"
) from ex
return None
async def _async_update_data(self) -> CometBlueCoordinatorData:
"""Poll the device."""
data: CometBlueCoordinatorData = CometBlueCoordinatorData()
retry_count = 0
while retry_count < MAX_RETRIES and not data.temperatures:
try:
async with self.device:
# temperatures are required and must trigger a retry if not available
if not data.temperatures:
data.temperatures = await self.device.get_temperature_async()
# holiday is optional and should not trigger a retry
try:
if not data.holiday:
data.holiday = await self.device.get_holiday_async(1) or {}
except InvalidByteValueError as ex:
LOGGER.warning(
"Failed to retrieve optional data for %s: %s (%s)",
self.name,
type(ex).__name__,
ex,
)
except (InvalidByteValueError, TimeoutError, BleakError) as ex:
retry_count += 1
if retry_count >= MAX_RETRIES:
raise UpdateFailed(
f"Error retrieving data: {ex}", retry_after=30
) from ex
LOGGER.info(
"Retry updating %s after error: %s (%s)",
self.name,
type(ex).__name__,
ex,
)
await asyncio.sleep(COMMAND_RETRY_INTERVAL)
except Exception as ex:
raise UpdateFailed(
f"({type(ex).__name__}) {ex}", retry_after=30
) from ex
# If one value was not retrieved correctly, keep the old value
LOGGER.debug("Received data for %s: %s", self.name, data)
return data

View File

@@ -0,0 +1,33 @@
"""Coordinator entity base class for CometBlue."""
from homeassistant.components import bluetooth
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN
from .coordinator import CometBlueDataUpdateCoordinator
class CometBlueBluetoothEntity(CoordinatorEntity[CometBlueDataUpdateCoordinator]):
"""Coordinator entity for CometBlue."""
_attr_has_entity_name = True
def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None:
"""Initialize coordinator entity."""
super().__init__(coordinator)
# Full DeviceInfo is added to DeviceRegistry in __init__.py, so we only
# set identifiers here to link the entity to the device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.coordinator.address)},
)
@property
def available(self) -> bool:
"""Return if entity is available."""
# As long the device is currently connectable via Bluetooth it is available, even if the last update failed.
# This is because Bluetooth connectivity can be intermittent and a failed update doesn't necessarily mean the device is unavailable.
# The BluetoothManager will check every 300s (same interval as DataUpdateCoordinator) if the device is still present and connectable.
return bluetooth.async_address_present(
self.hass, address=self.coordinator.address, connectable=True
)

View File

@@ -0,0 +1,19 @@
{
"domain": "eurotronic_cometblue",
"name": "Eurotronic Comet Blue",
"bluetooth": [
{
"connectable": true,
"service_uuid": "47e9ee00-47e9-11e4-8939-164230d1df67"
}
],
"codeowners": ["@rikroe"],
"config_flow": true,
"dependencies": ["bluetooth"],
"documentation": "https://www.home-assistant.io/integrations/eurotronic_cometblue",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eurotronic_cometblue_ha"],
"quality_scale": "bronze",
"requirements": ["eurotronic-cometblue-ha==1.4.0"]
}

View File

@@ -0,0 +1,88 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide 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 actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not subscribe to any 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: This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration does not login to any device or service.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration relies on MAC-based BLE connections.
discovery: done
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: todo
dynamic-devices: done
entity-category:
status: exempt
comment: This integration only provides one primary entity.
entity-device-class:
status: exempt
comment: This integration does not provide sensors.
entity-disabled-by-default:
status: exempt
comment: This integration only provides one primary entity.
entity-translations:
status: exempt
comment: This integration only provides one primary entity.
exception-translations: todo
icon-translations:
status: exempt
comment: Not required.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Not required.
stale-devices:
status: exempt
comment: Only single device per config entry.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: This integration does not make any HTTP requests.
strict-typing: todo

View File

@@ -0,0 +1,33 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "No Comet Blue Bluetooth TRVs discovered.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_pin": "Invalid device PIN",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"pin": "6-digit device PIN"
}
},
"pick_device": {
"data": {
"address": "Discovered devices"
},
"data_description": {
"address": "Select device to continue."
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN)
@@ -44,6 +44,7 @@ __all__ = [
"DOMAIN",
"PLATFORM_SCHEMA",
"PLATFORM_SCHEMA_BASE",
"DoorbellEventType",
"EventDeviceClass",
"EventEntity",
"EventEntityDescription",
@@ -189,6 +190,21 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
async def async_internal_added_to_hass(self) -> None:
"""Call when the event entity is added to hass."""
await super().async_internal_added_to_hass()
if (
self.device_class == EventDeviceClass.DOORBELL
and DoorbellEventType.RING not in self.event_types
):
report_issue = self._suggest_report_issue()
_LOGGER.warning(
"Entity %s is a doorbell event entity but does not support "
"the '%s' event type. This will stop working in "
"Home Assistant 2027.4, please %s",
self.entity_id,
DoorbellEventType.RING,
report_issue,
)
if (
(state := await self.async_get_last_state())
and state.state is not None

View File

@@ -1,5 +1,13 @@
"""Provides the constants needed for the component."""
from enum import StrEnum
DOMAIN = "event"
ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_TYPES = "event_types"
class DoorbellEventType(StrEnum):
"""Standard event types for doorbell device class."""
RING = "ring"

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