Compare commits

...

150 Commits

Author SHA1 Message Date
Robert Resch
46f8374063 Use runtime_data in plaato integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:44:15 +00: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
567 changed files with 17259 additions and 7518 deletions

View File

@@ -108,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
@@ -119,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
@@ -344,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 }}
@@ -499,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
@@ -523,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 }}

2
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

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

@@ -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
@@ -71,6 +70,7 @@ from .const import (
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -112,25 +112,13 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
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
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(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
for model_info in models
]
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):

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) -> anthropic.types.ModelInfo:
"""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
# Second try: match by alias
alias = model_alias(model_id)
for model in self.data or []:
if model_alias(model.id) == alias:
return model
# 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=model_id,
)

View File

@@ -689,12 +689,17 @@ 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,
)
@@ -969,7 +974,7 @@ 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,
@@ -982,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

@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.83.0"]
"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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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.2.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

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

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"

View File

@@ -15,7 +15,14 @@
"name": "Button"
},
"doorbell": {
"name": "Doorbell"
"name": "Doorbell",
"state_attributes": {
"event_type": {
"state": {
"ring": "Ring"
}
}
}
},
"motion": {
"name": "Motion"

View File

@@ -189,25 +189,25 @@ class EvoZone(EvoChild, EvoClimateEntity):
)
async def async_clear_zone_override(self) -> None:
"""Clear the zone's override, if any."""
"""Clear the zone override (if any) and return to following its schedule."""
await self.coordinator.call_client_api(self._evo_device.reset())
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Set the zone's override (mode/setpoint)."""
"""Override the zone's setpoint, either permanently or for a duration."""
temperature = max(min(setpoint, self.max_temp), self.min_temp)
if duration is not None:
if duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + duration
else:
if duration is None:
until = None # indefinitely
elif duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + duration
until = dt_util.as_utc(until) if until else None
await self.coordinator.call_client_api(
self._evo_device.set_temperature(temperature, until=until)
)

View File

@@ -27,7 +27,7 @@ from .coordinator import EvoDataUpdateCoordinator
# System service schemas (registered as domain services)
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# unsupported modes are rejected at runtime with ServiceValidationError
vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode)
vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),

View File

@@ -4,6 +4,8 @@
set_system_mode:
fields:
mode:
required: true
default: Auto
example: Away
selector:
select:
@@ -19,9 +21,10 @@ set_system_mode:
selector:
object:
duration:
example: '{"hours": 18}'
example: "18:00"
selector:
object:
duration:
enable_second: false
reset_system:
@@ -32,6 +35,8 @@ set_zone_override:
entity:
integration: evohome
domain: climate
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE
fields:
setpoint:
required: true
@@ -41,12 +46,15 @@ set_zone_override:
max: 35.0
step: 0.1
duration:
example: '{"minutes": 135}'
example: "02:15"
selector:
object:
duration:
enable_second: false
clear_zone_override:
target:
entity:
integration: evohome
domain: climate
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE

View File

@@ -4,13 +4,13 @@
"message": "The requested system mode is not supported: {error}"
},
"mode_cant_be_temporary": {
"message": "The mode `{mode}` does not support `duration` or `period`"
"message": "The mode `{mode}` does not support 'Duration' or 'Period'"
},
"mode_cant_have_duration": {
"message": "The mode `{mode}` does not support `duration`; use `period` instead"
"message": "The mode `{mode}` does not support 'Duration'; use 'Period' instead"
},
"mode_cant_have_period": {
"message": "The mode `{mode}` does not support `period`; use `duration` instead"
"message": "The mode `{mode}` does not support 'Period'; use 'Duration' instead"
},
"mode_not_supported": {
"message": "The mode `{mode}` is not supported by this controller"
@@ -29,14 +29,14 @@
"name": "Refresh system"
},
"reset_system": {
"description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode).",
"description": "Sets the system to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).",
"name": "Reset system"
},
"set_system_mode": {
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.",
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to `Auto`. Not all systems support all modes.",
"fields": {
"duration": {
"description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours).",
"description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).",
"name": "Duration"
},
"mode": {
@@ -44,14 +44,14 @@
"name": "[%key:common::config_flow::data::mode%]"
},
"period": {
"description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1).",
"description": "A period of time in days; used only with `Away`, `DayOff`, or `Custom` mode. The system will revert to `Auto` mode at midnight (up to 99 days, today is day 1).",
"name": "Period"
}
},
"name": "Set system mode"
},
"set_zone_override": {
"description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
"description": "Overrides the zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
"fields": {
"duration": {
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",

View File

@@ -69,7 +69,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
"""Base for any evohome-compatible DHW controller."""
_attr_name = "DHW controller"
_attr_icon = "mdi:thermometer-lines"
_attr_operation_list = list(HA_STATE_TO_EVO)
_attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE

View File

@@ -2,18 +2,14 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from .coordinator import FlussDataUpdateCoordinator
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON]
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,

View File

@@ -1,16 +1,13 @@
"""Support for Fluss Devices."""
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -453,10 +453,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
if not attributes.get("MACAddress"):
continue
wan_access_result = None
if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None:
wan_access_result = "granted" in wan_access
else:
wan_access_result = None
# wan_access can be "granted", "denied", "unknown" or "error"
if "granted" in wan_access:
wan_access_result = True
elif "denied" in wan_access:
wan_access_result = False
hosts[attributes["MACAddress"]] = Device(
name=attributes["HostName"],

View File

@@ -11,4 +11,8 @@ CONF_LISTENING_PORT_DEFAULT = 4002
CONF_DISCOVERY_INTERVAL_DEFAULT = 60
SCAN_INTERVAL = timedelta(seconds=30)
# A device is considered unavailable if we have not heard a status response
# from it for three consecutive poll cycles. This tolerates a single dropped
# UDP response plus some jitter before flapping the entity state.
DEVICE_TIMEOUT = SCAN_INTERVAL * 3
DISCOVERY_TIMEOUT = 5

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
@@ -22,7 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .const import DEVICE_TIMEOUT, DOMAIN, MANUFACTURER
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -118,6 +119,19 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
serial_number=device.fingerprint,
)
@property
def available(self) -> bool:
"""Return if the device is reachable.
The underlying library updates ``lastseen`` whenever the device
replies to a status request. The coordinator polls every
``SCAN_INTERVAL``, so if we have not heard back within
``DEVICE_TIMEOUT`` we consider the device offline.
"""
if not super().available:
return False
return datetime.now() - self._device.lastseen < DEVICE_TIMEOUT
@property
def is_on(self) -> bool:
"""Return true if device is on (brightness above 0)."""
@@ -205,8 +219,8 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
@callback
def _update_callback(self, device: GoveeDevice) -> None:
if self.hass:
self.async_write_ha_state()
"""Handle device state updates pushed by the library."""
self.async_write_ha_state()
def _save_last_color_state(self) -> None:
color_mode = self.color_mode

View File

@@ -34,15 +34,23 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
discovery-update-info:
status: exempt
comment: >-
Growatt data loggers use a generic OUI and serial-number DHCP hostname,
making reliable local discovery not implementable.
discovery:
status: exempt
comment: >-
Growatt data loggers use a generic OUI and serial-number DHCP hostname,
making reliable local discovery not implementable.
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: todo
entity-category: done
entity-device-class: done

View File

@@ -98,7 +98,9 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
start_date, end_date - timedelta(days=1), inc=True
)
# if no end_date is given, return only the next recurrence
return [recurrences.after(start_date, inc=True)]
if (next_date := recurrences.after(start_date, inc=True)) is None:
return []
return [next_date]
class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):

View File

@@ -42,6 +42,7 @@ BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confi
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off"
BSH_OPERATION_STATE_DELAYED_START = "BSH.Common.EnumType.OperationState.DelayedStart"
BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run"
BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause"
BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"

View File

@@ -21,6 +21,7 @@ from homeassistant.util import dt as dt_util, slugify
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
BSH_OPERATION_STATE_DELAYED_START,
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
@@ -624,6 +625,7 @@ class HomeConnectProgramSensor(HomeConnectSensor):
"""Return whether a program is running, paused or finished."""
status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
return status is not None and status.value in [
BSH_OPERATION_STATE_DELAYED_START,
BSH_OPERATION_STATE_RUN,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_FINISHED,

View File

@@ -68,8 +68,16 @@ PROGRAM_OPTIONS = {
),
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool,
OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool,
OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool,
OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool,
OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool,
OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool,
OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool,
}.items()
}

View File

@@ -119,7 +119,7 @@ set_program_and_options:
- cooking_common_program_hood_automatic
- cooking_common_program_hood_venting
- cooking_common_program_hood_delayed_shut_off
- cooking_oven_program_heating_mode_3_d_heating
- cooking_oven_program_heating_mode_3_d_hot_air
- cooking_oven_program_heating_mode_air_fry
- cooking_oven_program_heating_mode_grill_large_area
- cooking_oven_program_heating_mode_grill_small_area
@@ -210,6 +210,7 @@ set_program_and_options:
mode: box
unit_of_measurement: "%"
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
required: false
selector:
select:
@@ -222,7 +223,7 @@ set_program_and_options:
collapsed: true
fields:
consumer_products_cleaning_robot_option_reference_map_id:
example: consumer_products_cleaning_robot_enum_type_available_maps_map1
example: consumer_products_cleaning_robot_enum_type_available_maps_map_1
required: false
selector:
select:
@@ -230,9 +231,9 @@ set_program_and_options:
translation_key: available_maps
options:
- consumer_products_cleaning_robot_enum_type_available_maps_temp_map
- consumer_products_cleaning_robot_enum_type_available_maps_map1
- consumer_products_cleaning_robot_enum_type_available_maps_map2
- consumer_products_cleaning_robot_enum_type_available_maps_map3
- consumer_products_cleaning_robot_enum_type_available_maps_map_1
- consumer_products_cleaning_robot_enum_type_available_maps_map_2
- consumer_products_cleaning_robot_enum_type_available_maps_map_3
consumer_products_cleaning_robot_option_cleaning_mode:
example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
required: false
@@ -310,7 +311,7 @@ set_program_and_options:
- consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
consumer_products_coffee_maker_option_bean_container:
consumer_products_coffee_maker_option_bean_container_selection:
example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
required: false
selector:
@@ -468,8 +469,8 @@ set_program_and_options:
hood_options:
collapsed: true
fields:
cooking_hood_option_venting_level:
example: cooking_hood_enum_type_stage_fan_stage01
cooking_common_option_hood_venting_level:
example: cooking_hood_enum_type_stage_fan_stage_01
required: false
selector:
select:
@@ -482,8 +483,8 @@ set_program_and_options:
- cooking_hood_enum_type_stage_fan_stage_03
- cooking_hood_enum_type_stage_fan_stage_04
- cooking_hood_enum_type_stage_fan_stage_05
cooking_hood_option_intensive_level:
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
cooking_common_option_hood_intensive_level:
example: cooking_hood_enum_type_intensive_stage_intensive_stage_1
required: false
selector:
select:
@@ -491,8 +492,8 @@ set_program_and_options:
translation_key: intensive_level
options:
- cooking_hood_enum_type_intensive_stage_intensive_stage_off
- cooking_hood_enum_type_intensive_stage_intensive_stage1
- cooking_hood_enum_type_intensive_stage_intensive_stage2
- cooking_hood_enum_type_intensive_stage_intensive_stage_1
- cooking_hood_enum_type_intensive_stage_intensive_stage_2
oven_options:
collapsed: true
fields:
@@ -567,7 +568,7 @@ set_program_and_options:
- laundry_care_washer_enum_type_temperature_ul_hot
- laundry_care_washer_enum_type_temperature_ul_extra_hot
laundry_care_washer_option_spin_speed:
example: laundry_care_washer_enum_type_spin_speed_r_p_m800
example: laundry_care_washer_enum_type_spin_speed_r_p_m_800
required: false
selector:
select:
@@ -611,12 +612,12 @@ set_program_and_options:
required: false
selector:
boolean:
laundry_care_washer_option_i_dos1_active:
laundry_care_washer_option_i_dos_1_active:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_i_dos2_active:
laundry_care_washer_option_i_dos_2_active:
example: false
required: false
selector:
@@ -656,7 +657,7 @@ set_program_and_options:
required: false
selector:
boolean:
laundry_care_washer_option_vario_perfect:
laundry_care_common_option_vario_perfect:
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
required: false
selector:

View File

@@ -260,7 +260,7 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
@@ -431,7 +431,7 @@
}
},
"bean_container": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]",
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]"
@@ -484,9 +484,9 @@
"current_map": {
"name": "Current map",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
}
},
@@ -557,19 +557,19 @@
}
},
"intensive_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]",
"state": {
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]"
}
},
"reference_map_id": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
}
},
@@ -620,7 +620,7 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
@@ -786,7 +786,7 @@
}
},
"vario_perfect": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]",
"state": {
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
@@ -794,7 +794,7 @@
}
},
"venting_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]",
"state": {
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
"cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
@@ -1272,10 +1272,10 @@
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
},
"i_dos1_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]"
},
"i_dos2_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]"
},
"intensiv_zone": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
@@ -1458,9 +1458,9 @@
},
"available_maps": {
"options": {
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3",
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1",
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2",
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3",
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map"
}
},
@@ -1584,8 +1584,8 @@
},
"intensive_level": {
"options": {
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1",
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2",
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1",
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2",
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off"
}
},
@@ -1629,7 +1629,7 @@
"cooking_common_program_hood_automatic": "Automatic",
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
"cooking_common_program_hood_venting": "Venting",
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
"cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air",
"cooking_oven_program_heating_mode_air_fry": "Air fry",
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
@@ -1892,7 +1892,7 @@
"description": "Describes the amount of coffee beans used in a coffee machine program.",
"name": "Bean amount"
},
"consumer_products_coffee_maker_option_bean_container": {
"consumer_products_coffee_maker_option_bean_container_selection": {
"description": "Defines the preferred bean container.",
"name": "Bean container"
},
@@ -1920,11 +1920,11 @@
"description": "Defines if double dispensing is enabled.",
"name": "Multiple beverages"
},
"cooking_hood_option_intensive_level": {
"cooking_common_option_hood_intensive_level": {
"description": "Defines the intensive setting.",
"name": "Intensive level"
},
"cooking_hood_option_venting_level": {
"cooking_common_option_hood_venting_level": {
"description": "Defines the required fan setting.",
"name": "Venting level"
},
@@ -1992,15 +1992,19 @@
"description": "Defines if the silent mode is activated.",
"name": "Silent mode"
},
"laundry_care_common_option_vario_perfect": {
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
"name": "Vario perfect"
},
"laundry_care_dryer_option_drying_target": {
"description": "Describes the drying target for a dryer program.",
"name": "Drying target"
},
"laundry_care_washer_option_i_dos1_active": {
"laundry_care_washer_option_i_dos_1_active": {
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)",
"name": "i-Dos 1 Active"
},
"laundry_care_washer_option_i_dos2_active": {
"laundry_care_washer_option_i_dos_2_active": {
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
"name": "i-Dos 2 Active"
},
@@ -2044,10 +2048,6 @@
"description": "Defines the temperature of the washing program.",
"name": "Temperature"
},
"laundry_care_washer_option_vario_perfect": {
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
"name": "Vario perfect"
},
"laundry_care_washer_option_water_plus": {
"description": "Defines if the water plus option is activated.",
"name": "Water +"

View File

@@ -13,7 +13,7 @@ from homeassistant.components.homeassistant_hardware.util import guess_firmware_
from homeassistant.components.usb import (
USBDevice,
async_register_port_event_callback,
scan_serial_ports,
async_scan_serial_ports,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -163,7 +163,7 @@ async def async_migrate_entry(
key not in config_entry.data
for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER)
):
serial_ports = await hass.async_add_executor_job(scan_serial_ports)
serial_ports = await async_scan_serial_ports(hass)
serial_ports_info = {port.device: port for port in serial_ports}
device = config_entry.data[DEVICE]

View File

@@ -103,6 +103,7 @@ CHARACTERISTIC_PLATFORMS = {
CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor",
CharacteristicsTypes.THREAD_CONTROL_POINT: "button",
CharacteristicsTypes.MUTE: "switch",
CharacteristicsTypes.AIRPLAY_ENABLE: "switch",
CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor",
CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch",
CharacteristicsTypes.TEMPERATURE_UNITS: "select",

View File

@@ -36,6 +36,9 @@
}
},
"switch": {
"airplay_enable": {
"default": "mdi:cast-variant"
},
"lock_physical_controls": {
"default": "mdi:lock-open"
},

View File

@@ -70,6 +70,12 @@ SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = {
translation_key="sleep_mode",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.AIRPLAY_ENABLE: DeclarativeSwitchEntityDescription(
key=CharacteristicsTypes.AIRPLAY_ENABLE,
name="AirPlay Enable",
translation_key="airplay_enable",
entity_category=EntityCategory.CONFIG,
),
}

View File

@@ -59,6 +59,43 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
try:
huum = Huum(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
await huum.status()
except Forbidden, NotAuthenticated:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unknown error")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reconfigure_entry,
title=user_input[CONF_USERNAME],
data_updates=user_input,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]},
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@@ -64,7 +64,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: Integration has no repair scenarios.

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -20,6 +21,16 @@
"description": "The authentication for {username} is no longer valid. Please enter the current password.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::huum::config::step::user::data_description::password%]",
"username": "[%key:component::huum::config::step::user::data_description::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -18,12 +18,19 @@ from iaqualink.device import (
AqualinkSwitch,
AqualinkThermostat,
)
from iaqualink.exception import AqualinkServiceException
from iaqualink.exception import (
AqualinkServiceException,
AqualinkServiceUnauthorizedException,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
@@ -74,11 +81,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
)
try:
await aqualink.login()
except AqualinkServiceException as login_exception:
_LOGGER.error("Failed to login: %s", login_exception)
except AqualinkServiceUnauthorizedException as auth_exception:
await aqualink.close()
return False
except (TimeoutError, httpx.HTTPError) as aio_exception:
raise ConfigEntryAuthFailed(
"Invalid credentials for iAqualink"
) from auth_exception
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception:
await aqualink.close()
raise ConfigEntryNotReady(
f"Error while attempting login: {aio_exception}"
@@ -94,9 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
systems = list(systems.values())
if not systems:
_LOGGER.error("No systems detected or supported")
await aqualink.close()
return False
raise ConfigEntryError("No systems detected or supported")
runtime_data = AqualinkRuntimeData(
aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[]

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "platinum",
"requirements": ["aioimmich==0.12.1"]
"requirements": ["aioimmich==0.14.0"]
}

View File

@@ -124,11 +124,11 @@ class ImmichMediaSource(MediaSource):
identifier=f"{identifier.unique_id}|{collection}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=collection,
title=collection.split("|", maxsplit=1)[0],
can_play=False,
can_expand=True,
)
for collection in ("albums", "people", "tags")
for collection in ("albums", "favorites|favorites", "people", "tags")
]
# --------------------------------------------------------
@@ -239,6 +239,12 @@ class ImmichMediaSource(MediaSource):
)
except ImmichError:
return []
elif identifier.collection == "favorites":
LOGGER.debug("Render all assets for favorites collection")
try:
assets = await immich_api.search.async_get_all_favorites()
except ImmichError:
return []
ret: list[BrowseMediaSource] = []
for asset in assets:

View File

@@ -1,10 +1,10 @@
{
"domain": "insteon",
"name": "Insteon",
"after_dependencies": ["panel_custom", "usb"],
"after_dependencies": ["panel_custom"],
"codeowners": ["@teharris1"],
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"dependencies": ["http", "usb", "websocket_api"],
"dhcp": [
{
"macaddress": "000EF3*"

View File

@@ -11,7 +11,6 @@ from pyinsteon.address import Address
from pyinsteon.constants import ALDBStatus, DeviceAction
from pyinsteon.device_types.device_base import Device
from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event
from serial.tools import list_ports
from homeassistant.components import usb
from homeassistant.const import CONF_ADDRESS, Platform
@@ -172,35 +171,22 @@ def async_add_insteon_devices(
)
def get_usb_ports() -> dict[str, str]:
async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of USB ports and their friendly names."""
ports = list_ports.comports()
port_descriptions = {}
for port in ports:
vid: str | None = None
pid: str | None = None
if port.vid is not None and port.pid is not None:
usb_device = usb.usb_device_from_port(port)
vid = usb_device.vid
pid = usb_device.pid
dev_path = usb.get_serial_by_id(port.device)
for port in await usb.async_scan_serial_ports(hass):
human_name = usb.human_readable_device_name(
dev_path,
port.device,
port.serial_number,
port.manufacturer,
port.description,
vid,
pid,
port.vid if isinstance(port, usb.USBDevice) else None,
port.pid if isinstance(port, usb.USBDevice) else None,
)
port_descriptions[dev_path] = human_name
port_descriptions[port.device] = human_name
return port_descriptions
async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of USB ports and their friendly names."""
return await hass.async_add_executor_job(get_usb_ports)
def compute_device_name(ha_device) -> str:
"""Return the HA device name."""
return ha_device.name_by_user or ha_device.name

View File

@@ -143,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
try:
fireplace: UnifiedFireplace = (
await UnifiedFireplace.build_fireplace_from_common(
_construct_common_data(entry)
_construct_common_data(entry),
polling_enabled=False,
)
)
LOGGER.debug("Waiting for Fireplace to Initialize")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import aiohttp
from intellifire4py import UnifiedFireplace
from intellifire4py.control import IntelliFireController
from intellifire4py.model import IntelliFirePollData
@@ -11,8 +12,9 @@ from intellifire4py.read import IntelliFireDataProvider
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
@@ -52,6 +54,14 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData
return self.fireplace.control_api
async def _async_update_data(self) -> IntelliFirePollData:
try:
await self.fireplace.perform_poll()
except aiohttp.ClientResponseError as err:
if err.status == 403:
raise ConfigEntryAuthFailed("Authentication failed") from err
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
except (aiohttp.ClientError, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
return self.fireplace.data
@property

View File

@@ -1,5 +1,7 @@
"""Provide info to system health."""
from typing import Any
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
@@ -14,7 +16,7 @@ def async_register(
register.async_register_info(system_health_info)
async def system_health_info(hass):
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
return {
"api_endpoint_reachable": system_health.async_check_can_reach_url(

View File

@@ -123,6 +123,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
knx_module.ui_time_server_controller.start(
knx_module.xknx, knx_module.config_store.get_time_server_config()
)
knx_module.ui_expose_controller.start(
hass, knx_module.xknx, knx_module.config_store.get_exposes()
)
if CONF_KNX_EXPOSE in config:
knx_module.yaml_exposures.extend(
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
@@ -157,6 +160,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for exposure in knx_module.service_exposures.values():
exposure.async_remove()
knx_module.ui_time_server_controller.stop()
knx_module.ui_expose_controller.stop()
configured_platforms_yaml = {
platform

View File

@@ -58,6 +58,7 @@ from .expose import KnxExposeEntity, KnxExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
from .storage.expose_controller import ExposeController
from .storage.time_server import TimeServerController
from .telegrams import Telegrams
@@ -76,6 +77,7 @@ class KNXModule:
self.connected = False
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
self.ui_expose_controller = ExposeController()
self.ui_time_server_controller = TimeServerController()
self.entry = entry

View File

@@ -11,15 +11,16 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.util.ulid import ulid_now
from ..const import DOMAIN
from ..const import DOMAIN, KNX_MODULE_KEY
from . import migration
from .const import CONF_DATA
from .expose_controller import KNXExposeStoreModel, KNXExposeStoreOptionModel
from .time_server import KNXTimeServerStoreModel
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION: Final = 2
STORAGE_VERSION_MINOR: Final = 3
STORAGE_VERSION_MINOR: Final = 4
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"
type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
@@ -32,6 +33,7 @@ class KNXConfigStoreModel(TypedDict):
"""Represent KNX configuration store data."""
entities: KNXEntityStoreModel
expose: KNXExposeStoreModel
time_server: KNXTimeServerStoreModel
@@ -68,6 +70,10 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]):
# version 2.3 introduced in 2026.3
migration.migrate_2_2_to_2_3(old_data)
if old_major_version <= 2 and old_minor_version < 4:
# version 2.4 introduced in 2026.5
migration.migrate_2_3_to_2_4(old_data)
return old_data
@@ -87,6 +93,7 @@ class KNXConfigStore:
)
self.data = KNXConfigStoreModel( # initialize with default structure
entities={},
expose={},
time_server={},
)
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
@@ -99,6 +106,10 @@ class KNXConfigStore:
"Loaded KNX config data from storage. %s entity platforms",
len(self.data["entities"]),
)
_LOGGER.debug(
"Loaded KNX config data from storage. %s exposes",
len(self.data["expose"]),
)
def add_platform(
self, platform: Platform, controller: PlatformControllerBase
@@ -183,6 +194,48 @@ class KNXConfigStore:
if registry_entry.unique_id in unique_ids
]
def get_exposes(self) -> KNXExposeStoreModel:
"""Return KNX entity state expose configuration."""
return self.data["expose"]
def get_expose_groups(self) -> dict[str, list[str]]:
"""Return KNX entity state exposes and their group addresses."""
return {
entity_id: [option["ga"]["write"] for option in config]
for entity_id, config in self.data["expose"].items()
}
def get_expose_config(self, entity_id: str) -> list[KNXExposeStoreOptionModel]:
"""Return KNX entity state expose configuration for an entity."""
return self.data["expose"].get(entity_id, [])
async def update_expose(
self, entity_id: str, expose_config: list[KNXExposeStoreOptionModel]
) -> None:
"""Update KNX expose configuration for an entity."""
knx_module = self.hass.data[KNX_MODULE_KEY]
expose_controller = knx_module.ui_expose_controller
expose_controller.update_entity_expose(
self.hass, knx_module.xknx, entity_id, expose_config
)
self.data["expose"][entity_id] = expose_config
await self._store.async_save(self.data)
async def delete_expose(self, entity_id: str) -> None:
"""Delete KNX expose configuration for an entity."""
knx_module = self.hass.data[KNX_MODULE_KEY]
expose_controller = knx_module.ui_expose_controller
expose_controller.remove_entity_expose(entity_id)
try:
del self.data["expose"][entity_id]
except KeyError as err:
raise ConfigStoreException(
f"Entity not found in expose configuration: {entity_id}"
) from err
await self._store.async_save(self.data)
@callback
def get_time_server_config(self) -> KNXTimeServerStoreModel:
"""Return KNX time server configuration."""
@@ -191,7 +244,7 @@ class KNXConfigStore:
async def update_time_server_config(self, config: KNXTimeServerStoreModel) -> None:
"""Update time server configuration."""
self.data["time_server"] = config
knx_module = self.hass.data.get(DOMAIN)
knx_module = self.hass.data[KNX_MODULE_KEY]
if knx_module:
knx_module.ui_time_server_controller.start(knx_module.xknx, config)
await self._store.async_save(self.data)

View File

@@ -0,0 +1,154 @@
"""KNX configuration storage for entity state exposes."""
from typing import Any, NotRequired, TypedDict
import voluptuous as vol
from xknx import XKNX
from xknx.dpt import DPTBase
from xknx.telegram.address import parse_device_group_address
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
selector,
template as template_helper,
)
from ..expose import KnxExposeEntity, KnxExposeOptions
from .entity_store_validation import validate_config_store_data
from .knx_selector import GASelector
type KNXExposeStoreModel = dict[
str, list[KNXExposeStoreOptionModel] # entity_id: configuration
]
class KNXExposeStoreOptionModel(TypedDict):
"""Represent KNX entity state expose configuration for an entity."""
ga: dict[str, Any] # group address configuration with write and dpt
attribute: NotRequired[str]
cooldown: NotRequired[float]
default: NotRequired[Any]
periodic_send: NotRequired[float]
respond_to_read: NotRequired[bool]
value_template: NotRequired[str]
class KNXExposeDataModel(TypedDict):
"""Represent a loaded KNX expose config for validation."""
entity_id: str
options: list[KNXExposeStoreOptionModel]
def validate_expose_template_no_coerce(value: str) -> str:
"""Validate a value is a valid expose template without coercing it to a Template object."""
temp = cv.template(value) # validate template
if temp.is_static:
raise vol.Invalid(
"Static templates are not supported. Template should start with '{{' and end with '}}'"
)
return value # return original string for storage and later template creation
EXPOSE_OPTION_SCHEMA = vol.Schema(
{
vol.Required("ga"): GASelector(
state=False,
passive=False,
write_required=True,
dpt=["numeric", "enum", "complex", "string"],
),
vol.Optional("attribute"): str,
vol.Optional("default"): object,
vol.Optional("cooldown"): cv.positive_float, # frontend renders to duration
vol.Optional("periodic_send"): cv.positive_float,
vol.Optional("respond_to_read"): bool,
vol.Optional("value_template"): validate_expose_template_no_coerce,
}
)
EXPOSE_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(),
vol.Required("options"): [EXPOSE_OPTION_SCHEMA],
},
extra=vol.REMOVE_EXTRA,
)
def validate_expose_data(data: dict) -> KNXExposeDataModel:
"""Validate and convert expose configuration data."""
return validate_config_store_data(EXPOSE_CONFIG_SCHEMA, data) # type: ignore[return-value]
def _store_to_expose_option(
hass: HomeAssistant, config: KNXExposeStoreOptionModel
) -> KnxExposeOptions:
"""Convert config store option model to expose options."""
ga = parse_device_group_address(config["ga"]["write"])
dpt: type[DPTBase] = DPTBase.parse_transcoder(config["ga"]["dpt"]) # type: ignore[assignment]
value_template = None
if (_value_template_config := config.get("value_template")) is not None:
value_template = template_helper.Template(_value_template_config, hass)
return KnxExposeOptions(
group_address=ga,
dpt=dpt,
attribute=config.get("attribute"),
cooldown=config.get("cooldown", 0),
default=config.get("default"),
periodic_send=config.get("periodic_send", 0),
respond_to_read=config.get("respond_to_read", True),
value_template=value_template,
)
class ExposeController:
"""Controller class for UI entity exposures."""
def __init__(self) -> None:
"""Initialize entity expose controller."""
self._entity_exposes: dict[str, KnxExposeEntity] = {}
@callback
def stop(self) -> None:
"""Shutdown entity expose controller."""
for expose in self._entity_exposes.values():
expose.async_remove()
self._entity_exposes.clear()
@callback
def start(
self, hass: HomeAssistant, xknx: XKNX, config: KNXExposeStoreModel
) -> None:
"""Update entity expose configuration."""
if self._entity_exposes:
self.stop()
for entity_id, options in config.items():
self.update_entity_expose(hass, xknx, entity_id, options)
@callback
def update_entity_expose(
self,
hass: HomeAssistant,
xknx: XKNX,
entity_id: str,
expose_config: list[KNXExposeStoreOptionModel],
) -> None:
"""Update entity expose configuration for an entity."""
self.remove_entity_expose(entity_id)
expose_options = [
_store_to_expose_option(hass, config) for config in expose_config
]
expose = KnxExposeEntity(hass, xknx, entity_id, expose_options)
self._entity_exposes[entity_id] = expose
expose.async_register()
@callback
def remove_entity_expose(self, entity_id: str) -> None:
"""Remove entity expose configuration for an entity."""
if entity_id in self._entity_exposes:
self._entity_exposes.pop(entity_id).async_remove()

View File

@@ -55,3 +55,8 @@ def migrate_2_1_to_2_2(data: dict[str, Any]) -> None:
def migrate_2_2_to_2_3(data: dict[str, Any]) -> None:
"""Migrate from schema 2.2 to schema 2.3."""
data.setdefault("time_server", {})
def migrate_2_3_to_2_4(data: dict[str, Any]) -> None:
"""Migrate from schema 2.3 to schema 2.4."""
data.setdefault("expose", {})

View File

@@ -950,6 +950,48 @@
"description": "Add and manage KNX entities",
"title": "Entities"
},
"expose": {
"create": {
"add_expose": "Add expose",
"attribute": {
"description": "Expose changes of a specific attribute of the entity instead of the state. Optional. If the attribute is not set, the entity state is exposed."
},
"cooldown": {
"description": "Minimum time between consecutive sends. This can be used to prevent high traffic on the KNX bus when values change very frequently. Only the most recent value during the cooldown period is sent.",
"label": "Cooldown"
},
"default": {
"description": "The value to send if the entity state is `unavailable` or `unknown`, or if the attribute is not set. If `default` is omitted, nothing is sent in these cases, but the last known value remains available for read requests.",
"label": "Default value"
},
"entity": {
"description": "Home Assistant entity to expose state changes to the KNX bus.",
"label": "Entity"
},
"ga": {
"label": "Group address"
},
"periodic_send": {
"description": "Time interval to automatically resend the current value to the KNX bus, even if it hasnt changed.",
"label": "Periodic send interval"
},
"respond_to_read": {
"description": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::description%]",
"label": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::label%]"
},
"section_advanced_options": {
"title": "Advanced options"
},
"show_raw_values": "Show raw values",
"title": "Add exposure",
"value_template": {
"description": "Optionally transform the entity state or attribute value before sending it to KNX using a template. The template receives the entity state or attribute value as `value` variable.",
"label": "Value template"
}
},
"description": "Expose Home Assistant entity states to the KNX bus",
"title": "Expose"
},
"group_monitor": {
"description": "Monitor KNX group communication",
"title": "Group monitor"

View File

@@ -35,6 +35,7 @@ from .storage.entity_store_validation import (
EntityStoreValidationSuccess,
validate_entity_data,
)
from .storage.expose_controller import validate_expose_data
from .storage.serialize import get_serialized_schema
from .storage.time_server import validate_time_server_data
from .telegrams import (
@@ -68,6 +69,11 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_get_schema)
websocket_api.async_register_command(hass, ws_get_time_server_config)
websocket_api.async_register_command(hass, ws_update_time_server_config)
websocket_api.async_register_command(hass, ws_get_expose_groups)
websocket_api.async_register_command(hass, ws_get_expose_config)
websocket_api.async_register_command(hass, ws_update_expose)
websocket_api.async_register_command(hass, ws_delete_expose)
websocket_api.async_register_command(hass, ws_validate_expose)
if DOMAIN not in hass.data.get("frontend_panels", {}):
await hass.http.async_register_static_paths(
@@ -588,6 +594,142 @@ def ws_create_device(
connection.send_result(msg["id"], _device.dict_repr)
########
# Expose
########
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/get_expose_groups",
}
)
@provide_knx
@callback
def ws_get_expose_groups(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get exposes from config store."""
connection.send_result(msg["id"], knx.config_store.get_expose_groups())
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/get_expose_config",
vol.Required("entity_id"): str,
}
)
@provide_knx
@callback
def ws_get_expose_config(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get expose configuration from config store."""
connection.send_result(
msg["id"], knx.config_store.get_expose_config(msg["entity_id"])
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/update_expose",
vol.Required("entity_id"): str,
vol.Required("options"): list, # validation done in handler
}
)
@websocket_api.async_response
@provide_knx
async def ws_update_expose(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Update expose configuration in config store."""
try:
validated_data = validate_expose_data(msg)
except EntityStoreValidationException as exc:
connection.send_result(msg["id"], exc.validation_error)
return
try:
await knx.config_store.update_expose(
validated_data["entity_id"], validated_data["options"]
)
except ConfigStoreException as err:
connection.send_error(
msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err)
)
return
connection.send_result(
msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None)
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/delete_expose",
vol.Required("entity_id"): str,
}
)
@websocket_api.async_response
@provide_knx
async def ws_delete_expose(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Delete expose configuration from config store."""
try:
await knx.config_store.delete_expose(msg["entity_id"])
except ConfigStoreException as err:
connection.send_error(
msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err)
)
return
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/validate_expose",
vol.Required("entity_id"): str,
vol.Required("options"): list, # validation done in handler
}
)
@callback
def ws_validate_expose(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Validate expose data."""
try:
validate_expose_data(msg)
except EntityStoreValidationException as exc:
connection.send_result(msg["id"], exc.validation_error)
return
connection.send_result(
msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None)
)
#############
# Time server
#############
@websocket_api.require_admin
@websocket_api.websocket_command(
{

View File

@@ -7,7 +7,6 @@ import logging
from typing import Any
import serial
from serial.tools import list_ports
import ultraheat_api
import voluptuous as vol
@@ -45,9 +44,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input[CONF_DEVICE] == CONF_MANUAL_PATH:
return await self.async_step_setup_serial_manual_path()
dev_path = await self.hass.async_add_executor_job(
usb.get_serial_by_id, user_input[CONF_DEVICE]
)
dev_path = user_input[CONF_DEVICE]
_LOGGER.debug("Using this path : %s", dev_path)
try:
@@ -118,23 +115,19 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of USB ports and their friendly names."""
ports = await hass.async_add_executor_job(list_ports.comports)
ports = await usb.async_scan_serial_ports(hass)
port_descriptions = {}
for port in ports:
# this prevents an issue with usb_device_from_port
# not working for ports without vid on RPi
if port.vid:
usb_device = usb.usb_device_from_port(port)
dev_path = usb.get_serial_by_id(usb_device.device)
if isinstance(port, usb.USBDevice):
human_name = usb.human_readable_device_name(
dev_path,
usb_device.serial_number,
usb_device.manufacturer,
usb_device.description,
usb_device.vid,
usb_device.pid,
port.device,
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
)
port_descriptions[dev_path] = human_name
port_descriptions[port.device] = human_name
return port_descriptions

View File

@@ -62,14 +62,32 @@ class ResourceStorageCollection(collection.DictStorageCollection):
)
self.ll_config = ll_config
async def async_get_info(self) -> dict[str, int]:
"""Return the resources info for YAML mode."""
async def _async_ensure_loaded(self) -> None:
"""Ensure the collection has been loaded from storage."""
if not self.loaded:
await self.async_load()
self.loaded = True
async def async_get_info(self) -> dict[str, int]:
"""Return the resources info for YAML mode."""
await self._async_ensure_loaded()
return {"resources": len(self.async_items() or [])}
async def async_create_item(self, data: dict) -> dict:
"""Create a new item."""
await self._async_ensure_loaded()
return await super().async_create_item(data)
async def async_update_item(self, item_id: str, updates: dict) -> dict:
"""Update item."""
await self._async_ensure_loaded()
return await super().async_update_item(item_id, updates)
async def async_delete_item(self, item_id: str) -> None:
"""Delete item."""
await self._async_ensure_loaded()
await super().async_delete_item(item_id)
async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
"""Load the data."""
if (store_data := await self.store.async_load()) is not None:
@@ -118,10 +136,6 @@ class ResourceStorageCollection(collection.DictStorageCollection):
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
if not self.loaded:
await self.async_load()
self.loaded = True
update_data = self.UPDATE_SCHEMA(update_data)
if CONF_RESOURCE_TYPE_WS in update_data:
update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS)

View File

@@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(auth_api)
devices_api = Devices(info_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()

View File

@@ -5,15 +5,17 @@ from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -28,13 +30,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
url = URL(user_input[CONF_URL]).human_repr()[:-1]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
@@ -64,13 +70,58 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(title=url, data=data)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
url = URL.build(scheme="http", host=discovery_info.host).human_repr()[:-1]
uid = discovery_info.properties["uid"]
await self.async_set_unique_id(uid.replace("-", ""))
self._abort_if_unique_id_configured(updates={CONF_URL: url})
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
return self.async_abort(reason="invalid_url")
except aiohttp.ClientConnectionError:
return self.async_abort(reason="cannot_connect")
self._data[CONF_URL] = url
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the discovered device."""
if user_input is not None:
return self.async_create_entry(title=self._data[CONF_URL], data=self._data)
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders=self._data,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)
if user_input is not None:
return await self.async_step_user(user_input)
entry = self._get_reconfigure_entry()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{vol.Required(CONF_URL, default=entry.data[CONF_URL]): cv.string},
),
description_placeholders={CONF_NAME: entry.title},
)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from typing import Any
from lunatone_rest_api_client import DALIBroadcast
@@ -10,6 +9,9 @@ from lunatone_rest_api_client.models import LineStatus
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
brightness_supported,
@@ -28,7 +30,6 @@ from .coordinator import (
)
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
async def async_setup_entry(
@@ -74,6 +75,8 @@ class LunatoneLight(
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_min_color_temp_kelvin = 1000
_attr_max_color_temp_kelvin = 10000
def __init__(
self,
@@ -123,7 +126,13 @@ class LunatoneLight(
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device is not None and self._device.brightness is not None:
if self._device.rgbw_color is not None:
return ColorMode.RGBW
if self._device.rgb_color is not None:
return ColorMode.RGB
if self._device.color_temperature is not None:
return ColorMode.COLOR_TEMP
if self._device.brightness is not None:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@@ -132,6 +141,32 @@ class LunatoneLight(
"""Return the supported color modes."""
return {self.color_mode}
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temp of this light in kelvin."""
return self._device.color_temperature
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the RGB color of this light."""
rgb_color = self._device.rgb_color
return rgb_color and (
round(rgb_color[0] * 255),
round(rgb_color[1] * 255),
round(rgb_color[2] * 255),
)
@property
def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the RGBW color of this light."""
rgbw_color = self._device.rgbw_color
return rgbw_color and (
round(rgbw_color[0] * 255),
round(rgbw_color[1] * 255),
round(rgbw_color[2] * 255),
round(rgbw_color[3] * 255),
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -141,16 +176,26 @@ class LunatoneLight(
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if brightness_supported(self.supported_color_modes):
await self._device.fade_to_brightness(
brightness_to_value(
self.BRIGHTNESS_SCALE,
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
if ATTR_COLOR_TEMP_KELVIN in kwargs:
await self._device.fade_to_color_temperature(
kwargs[ATTR_COLOR_TEMP_KELVIN]
)
if ATTR_RGB_COLOR in kwargs:
await self._device.fade_to_rgbw_color(
tuple(color / 255 for color in kwargs[ATTR_RGB_COLOR])
)
if ATTR_RGBW_COLOR in kwargs:
rgbw_color = tuple(color / 255 for color in kwargs[ATTR_RGBW_COLOR])
await self._device.fade_to_rgbw_color(rgbw_color[:-1], rgbw_color[-1])
if ATTR_BRIGHTNESS in kwargs or not self.is_on:
await self._device.fade_to_brightness(
brightness_to_value(
self.BRIGHTNESS_SCALE,
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
)
)
)
else:
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -161,8 +206,6 @@ class LunatoneLight(
await self._device.fade_to_brightness(0)
else:
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
@@ -221,13 +264,9 @@ class LunatoneLineBroadcastLight(
await self._broadcast.fade_to_brightness(
brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255))
)
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self._coordinator_devices.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the line to turn off."""
await self._broadcast.fade_to_brightness(0)
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self._coordinator_devices.async_refresh()

View File

@@ -7,5 +7,15 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.7.0"]
"requirements": ["lunatone-rest-api-client==0.9.1"],
"zeroconf": [
{
"properties": {
"manufacturer": "lunatone industrielle elektronik gmbh",
"type": "dali-2-*",
"uid": "*"
},
"type": "_http._tcp.local."
}
]
}

View File

@@ -2,17 +2,19 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
"discovery_confirm": {
"description": "Do you want to setup the Lunatone device with {url}?"
},
"reconfigure": {
"data": {
@@ -21,16 +23,16 @@
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
},
"description": "Update the URL."
"description": "Update configuration for {name}."
},
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
"url": "The URL of the Lunatone device to connect to."
},
"description": "Connect to the API of your Lunatone DALI IoT Gateway."
"description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API."
}
}
}

View File

@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.27.0"],
"requirements": ["pylutron-caseta==0.28.0"],
"zeroconf": [
{
"properties": {

View File

@@ -71,6 +71,8 @@ class LockUserData(TypedDict):
user_type: str
credential_rule: str
credentials: list[LockUserCredentialData]
creator_fabric_index: int | None
last_modified_fabric_index: int | None
next_user_index: int | None
@@ -115,6 +117,8 @@ class GetLockCredentialStatusResult(TypedDict):
credential_exists: bool
user_index: int | None
creator_fabric_index: int | None
last_modified_fabric_index: int | None
next_credential_index: int | None
@@ -214,6 +218,8 @@ def _format_user_response(user_data: Any) -> LockUserData | None:
_get_attr(user_data, "credentialRule"), "unknown"
),
credentials=credentials,
creator_fabric_index=_get_attr(user_data, "creatorFabricIndex"),
last_modified_fabric_index=_get_attr(user_data, "lastModifiedFabricIndex"),
next_user_index=_get_attr(user_data, "nextUserIndex"),
)
@@ -817,7 +823,8 @@ async def get_lock_credential_status(
) -> GetLockCredentialStatusResult:
"""Get the status of a credential slot on the lock.
Returns typed dict with credential_exists, user_index, next_credential_index.
Returns typed dict with credential_exists, user_index, creator_fabric_index,
last_modified_fabric_index, and next_credential_index.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
@@ -839,5 +846,7 @@ async def get_lock_credential_status(
return GetLockCredentialStatusResult(
credential_exists=bool(_get_attr(response, "credentialExists")),
user_index=_get_attr(response, "userIndex"),
creator_fabric_index=_get_attr(response, "creatorFabricIndex"),
last_modified_fabric_index=_get_attr(response, "lastModifiedFabricIndex"),
next_credential_index=_get_attr(response, "nextCredentialIndex"),
)

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from typing import Any
from phone_modem import PhoneModem
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant.components import usb
@@ -19,9 +17,11 @@ from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS
DATA_SCHEMA = vol.Schema({"name": str, "device": str})
def _generate_unique_id(port: ListPortInfo) -> str:
def _generate_unique_id(port: usb.USBDevice | usb.SerialDevice) -> str:
"""Generate unique id from usb attributes."""
return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}"
vid = port.vid if isinstance(port, usb.USBDevice) else None
pid = port.pid if isinstance(port, usb.USBDevice) else None
return f"{vid}:{pid}_{port.serial_number}_{port.manufacturer}_{port.description}"
class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -62,30 +62,28 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = {}
if self._async_in_progress():
return self.async_abort(reason="already_in_progress")
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
ports = await usb.async_scan_serial_ports(self.hass)
existing_devices = [
entry.data[CONF_DEVICE] for entry in self._async_current_entries()
]
unused_ports = [
port_map = {
usb.human_readable_device_name(
port.device,
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
)
port.vid if isinstance(port, usb.USBDevice) else None,
port.pid if isinstance(port, usb.USBDevice) else None,
): port
for port in ports
if port.device not in existing_devices
]
if not unused_ports:
}
if not port_map:
return self.async_abort(reason="no_devices_found")
if user_input is not None:
port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))]
dev_path = await self.hass.async_add_executor_job(
usb.get_serial_by_id, port.device
)
port = port_map[user_input[CONF_DEVICE]]
dev_path = port.device
errors = await self.validate_device_errors(
dev_path=dev_path, unique_id=_generate_unique_id(port)
)
@@ -95,7 +93,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
data={CONF_DEVICE: dev_path},
)
user_input = user_input or {}
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)})
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))})
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def validate_device_errors(

View File

@@ -53,6 +53,8 @@ PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SWITCH,
Platform.TEXT,
]
CONNECT_TIMEOUT = 10

View File

@@ -82,3 +82,4 @@ ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
LOGGER = logging.getLogger(__package__)
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options."
SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode."

View File

@@ -60,6 +60,7 @@ from .const import (
ATTR_REPEAT_MODE,
ATTR_SHUFFLE_ENABLED,
DOMAIN,
SOUND_MODES_TRANSLATION_KEY_PREFIX,
)
from .entity import MusicAssistantEntity
from .helpers import catch_musicassistant_error
@@ -131,6 +132,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
_attr_name = None
_attr_media_image_remotely_accessible = True
_attr_media_content_type = HAMediaType.MUSIC
_attr_translation_key = "ma_media_player"
def __init__(self, mass: MusicAssistantClient, player_id: str) -> None:
"""Initialize MediaPlayer entity."""
@@ -140,6 +142,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
self._source_list_mapping: dict[str, str] = {}
self._sound_mode_list_mapping: dict[str, str] = {}
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -218,6 +221,29 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._source_list_mapping = source_mappings
self._attr_source = active_source_name
# same for sound modes
sound_mode_mappings: dict[str, str] = {}
for sound_mode in player.sound_mode_list:
if sound_mode.passive:
# ignore passive sound_mode because HA does not differentiate between
# active and passive sound mode
continue
if (
sound_mode.translation_key is None
or SOUND_MODES_TRANSLATION_KEY_PREFIX not in sound_mode.translation_key
):
# MA's data class initializes the translation_key to
# player_sound_mode.<id> automatically if it is not given, so we should
# always have a non None value
continue
translation_key = sound_mode.translation_key[
len(SOUND_MODES_TRANSLATION_KEY_PREFIX) :
]
sound_mode_mappings[translation_key] = sound_mode.id
self._attr_sound_mode_list = list(sound_mode_mappings.keys())
self._sound_mode_list_mapping = sound_mode_mappings
self._attr_sound_mode = player.active_sound_mode
group_members: list[str] = []
if player.group_members:
group_members = player.group_members
@@ -397,6 +423,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
await self.mass.players.player_command_select_source(self.player_id, source_id)
@catch_musicassistant_error
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select sound mode."""
sound_mode_id = self._sound_mode_list_mapping.get(sound_mode)
if sound_mode_id is None:
raise ServiceValidationError(
f"Sound mode '{sound_mode}' not found for player {self.name}"
)
await self.mass.players.select_sound_mode(self.player_id, sound_mode_id)
@catch_musicassistant_error
async def _async_handle_play_media(
self,
@@ -682,4 +718,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
supported_features |= MediaPlayerEntityFeature.TURN_OFF
if PlayerFeature.SELECT_SOURCE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE
if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_supported_features = supported_features

View File

@@ -54,6 +54,69 @@
"name": "Favorite current song"
}
},
"media_player": {
"ma_media_player": {
"state_attributes": {
"sound_mode": {
"state": {
"2ch_stereo": "2ch stereo",
"5ch_stereo": "5ch stereo",
"7ch_stereo": "7ch stereo",
"9ch_stereo": "9ch stereo",
"11ch_stereo": "11ch stereo",
"action_game": "Action game",
"adventure": "Adventure",
"all_ch_stereo": "All ch stereo",
"amsterdam": "Hall in Amsterdam",
"arena": "Arena",
"bottom_line": "The Bottom Line",
"cellar_club": "Cellar club",
"chamber": "Chamber",
"concert": "Live concert",
"disco": "Disco",
"drama": "Drama",
"enhanced": "Enhanced",
"frankfurt": "Hall in Frankfurt",
"freiburg": "Church in Freiburg",
"game": "Game",
"jazz_club": "Jazz club",
"mono_movie": "Mono movie",
"movie": "Movie",
"munich": "Hall in Munich",
"munich_a": "Hall in Munich A",
"munich_b": "Hall in Munich B",
"music": "Music",
"music_video": "Music video",
"my_surround": "My surround",
"off": "[%key:common::state::off%]",
"pavilion": "Pavilion",
"recital_opera": "Recital/opera",
"roleplaying_game": "Roleplaying game",
"roxy_theatre": "The Roxy Theatre",
"royaumont": "Church in Royaumont",
"sci-fi": "Sci-fi",
"spectacle": "Spectacle",
"sports": "Sports",
"standard": "Standard",
"stereo": "Stereo",
"straight": "Straight",
"stuttgart": "Hall in Stuttgart",
"surr_decoder": "Surround decoder",
"talk_show": "Talk show",
"target": "Target",
"tokyo": "Church in Tokyo",
"tv_program": "TV program",
"usa_a": "Hall in USA A",
"usa_b": "Hall in USA B",
"vienna": "Hall in Vienna",
"village_gate": "Village Gate",
"village_vanguard": "Village Vanguard",
"warehouse_loft": "Warehouse loft"
}
}
}
}
},
"number": {
"bass": {
"name": "Bass"
@@ -82,6 +145,43 @@
"treble": {
"name": "Treble"
}
},
"switch": {
"adaptive_drc": {
"name": "Adaptive DRC"
},
"bass_extension": {
"name": "Bass extension"
},
"clear_voice": {
"name": "Clear voice"
},
"enhancer": {
"name": "Enhancer"
},
"extra_bass": {
"name": "Extra bass"
},
"party_mode": {
"name": "Party mode"
},
"pure_direct": {
"name": "Pure direct"
},
"speaker_a": {
"name": "Speaker A"
},
"speaker_b": {
"name": "Speaker B"
},
"surround_3d": {
"name": "Surround 3D"
}
},
"text": {
"network_name": {
"name": "Network name"
}
}
},
"issues": {

View File

@@ -0,0 +1,118 @@
"""Music Assistant Switch platform."""
from __future__ import annotations
from typing import Any, Final
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.player import PlayerOption, PlayerOptionType
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
from .entity import MusicAssistantPlayerOptionEntity
from .helpers import catch_musicassistant_error
PLAYER_OPTIONS_SWITCH: Final[dict[str, bool]] = {
# translation_key: enabled_by_default
"adaptive_drc": False,
"bass_extension": False,
"clear_voice": False,
"enhancer": True,
"extra_bass": False,
"party_mode": False,
"pure_direct": True,
"speaker_a": True,
"speaker_b": True,
"surround_3d": False,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant Switch Entities (Player Options) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
player = mass.players.get(player_id)
if player is None:
return
entities: list[MusicAssistantPlayerConfigSwitch] = []
for player_option in player.options:
if (
not player_option.read_only
and player_option.type == PlayerOptionType.BOOLEAN
):
# the MA translation key must have the format player_options.<translation key>
# we ignore entities with unknown translation keys.
if (
player_option.translation_key is None
or not player_option.translation_key.startswith(
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
)
):
continue
translation_key = player_option.translation_key[
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
]
if translation_key not in PLAYER_OPTIONS_SWITCH:
continue
entities.append(
MusicAssistantPlayerConfigSwitch(
mass,
player_id,
player_option=player_option,
entity_description=SwitchEntityDescription(
key=player_option.key,
translation_key=translation_key,
entity_registry_enabled_default=PLAYER_OPTIONS_SWITCH[
translation_key
],
),
)
)
async_add_entities(entities)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.SWITCH, add_player)
class MusicAssistantPlayerConfigSwitch(MusicAssistantPlayerOptionEntity, SwitchEntity):
"""Representation of a Switch entity to control player provider dependent settings."""
def __init__(
self,
mass: MusicAssistantClient,
player_id: str,
player_option: PlayerOption,
entity_description: SwitchEntityDescription,
) -> None:
"""Initialize MusicAssistantPlayerConfigSwitch."""
super().__init__(mass, player_id, player_option)
self.entity_description = entity_description
@catch_musicassistant_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Handle turn on command."""
await self.mass.players.set_option(self.player_id, self.mass_option_key, True)
@catch_musicassistant_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Handle turn off command."""
await self.mass.players.set_option(self.player_id, self.mass_option_key, False)
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Update on player option update."""
self._attr_is_on = (
player_option.value if isinstance(player_option.value, bool) else None
)

View File

@@ -0,0 +1,101 @@
"""Music Assistant text platform."""
from __future__ import annotations
from typing import Final
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.player import PlayerOption, PlayerOptionType
from homeassistant.components.text import TextEntity, TextEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
from .entity import MusicAssistantPlayerOptionEntity
from .helpers import catch_musicassistant_error
PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: Final[list[str]] = [
"network_name",
]
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant text Entities (Player Options) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
player = mass.players.get(player_id)
if player is None:
return
entities: list[MusicAssistantPlayerConfigText] = []
for player_option in player.options:
if (
not player_option.read_only
and player_option.type == PlayerOptionType.STRING
and not player_option.options # these we map to select
):
# the MA translation key must have the format player_options.<translation key>
# we ignore entities with unknown translation keys.
if (
player_option.translation_key is None
or not player_option.translation_key.startswith(
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
)
):
continue
translation_key = player_option.translation_key[
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
]
if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT:
continue
entities.append(
MusicAssistantPlayerConfigText(
mass,
player_id,
player_option=player_option,
entity_description=TextEntityDescription(
key=player_option.key,
translation_key=translation_key,
),
)
)
async_add_entities(entities)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.TEXT, add_player)
class MusicAssistantPlayerConfigText(MusicAssistantPlayerOptionEntity, TextEntity):
"""Representation of a text entity to control player provider dependent settings."""
def __init__(
self,
mass: MusicAssistantClient,
player_id: str,
player_option: PlayerOption,
entity_description: TextEntityDescription,
) -> None:
"""Initialize MusicAssistantPlayerConfigtext."""
super().__init__(mass, player_id, player_option)
self.entity_description = entity_description
@catch_musicassistant_error
async def async_set_value(self, value: str) -> None:
"""Set text value."""
await self.mass.players.set_option(self.player_id, self.mass_option_key, value)
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Update on player option update."""
self._attr_native_value = (
player_option.value if isinstance(player_option.value, str) else None
)

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