Compare commits

..

226 Commits

Author SHA1 Message Date
jbouwh
8e5a0cdacd Move translabale URL out of strings.json for knx integration 2025-10-27 07:53:09 +00:00
Robert Resch
39d76a24db Fix missing Ecovacs station actions (#155237) 2025-10-27 08:06:27 +01:00
Franck Nijhof
3e17a97422 Merge branch 'master' into dev 2025-10-27 05:28:14 +00:00
Erwin Douna
e6e78f86bd Bump pyfirefly 1.0.8 (#155226) 2025-10-26 22:34:36 +02:00
Maciej Bieniek
a72fe28d7a Support Shelly RGBCCT lights (#155197) 2025-10-26 21:14:27 +02:00
Angel Nunez Mencias
fa7ff1d996 update ttn_client to 1.2.3 (#155204) 2025-10-26 18:52:52 +02:00
Viktor Andersson
2cc910f4b1 SMHI switch thunder icon to weather-lightning (#155205) 2025-10-26 16:48:37 +01:00
Thomas55555
b686e4d8db Bump aioautomower to 2.6.0 (#155200) 2025-10-26 16:00:57 +02:00
Jordan Harvey
07493e5b3e Add missing tests for Nintendo Parental controls integration (#154875) 2025-10-26 15:57:58 +02:00
Allen Porter
477073da75 Bump python-roborock to 3.3.3 (#155170) 2025-10-26 15:28:49 +02:00
Paulus Schoutsen
b59bc45ef1 Fix typo in Google Gen AI const (#155196) 2025-10-26 15:20:43 +02:00
Robert Resch
da055795c7 Bump deebot-client to 16.1.0 (#155168) 2025-10-26 14:20:10 +01:00
Maciej Bieniek
3cd17a2b9f Add mute alarm button for Shelly Plus Smoke (#154673) 2025-10-26 12:41:16 +01:00
Matthias Alphart
d689400b3b Update knx-frontend to 2025.10.26.81530 (#155186) 2025-10-26 12:11:37 +01:00
Shay Levy
a1f024eed8 Update IQS for LG webOS TV integration (#155189) 2025-10-26 13:04:26 +02:00
Shay Levy
07b6358fff Fix LG webOS TV entity availability status (#155164) 2025-10-26 06:20:24 +01:00
Manu
6fa73f7f6a Deprecate entities in Xbox integration (#154891) 2025-10-26 06:13:45 +01:00
Luke Lashley
3c46b40cee Raise an issue when the Roborock local api is unavailable. (#154576)
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
2025-10-25 21:04:24 -07:00
Joost Lekkerkerker
4c9810a10e Bump yt-dlp to 2025.10.22 (#155174) 2025-10-26 01:34:37 +03:00
johanzander
83e9fca6a2 Adds support for controlling Growatt MIN/TLX inverters through number platform and entities (#153886)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-26 00:23:52 +02:00
Matthias Alphart
fc9313f7ef Support KNX climate entity configuration from UI (#154162)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-25 23:50:14 +02:00
Matthias Alphart
278f32285a Allow KNX UI BinarySensors to disable state synchronisation (#155054) 2025-10-25 23:49:21 +02:00
Erwin Douna
8c360908ef Bump Pyportainer to 1.0.9 (#155171) 2025-10-25 23:43:30 +02:00
Simone Chemelli
82c5337fcf Bump awesomeversion to 25.8.0 (#155172) 2025-10-26 00:42:15 +03:00
Ludovic BOUÉ
7950f9ab38 Add Matter service actions for water_heater (#153577)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-25 23:14:29 +02:00
Duco Sebel
66eeb41e56 Add product name to title of HomeWizard v2 API migration repair (#155097)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-25 14:04:07 -07:00
Retha Runolfsson
1bef707cd1 Add support for switchbot climate panel (#155124) 2025-10-25 22:56:50 +02:00
Shay Levy
2125a4123d Add zones support to Shelly Irrigation controller (#152382) 2025-10-25 22:33:20 +02:00
Niracler Li
27516dee6a Add DALI Center integration (#151479)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-25 22:15:56 +02:00
G Johansson
40c9e5356e _abort_if_unique_id_configured no automatic reload in deconz (#155141) 2025-10-25 22:13:34 +02:00
hahn-th
2521920376 Bump homematicip to 2.3.1 (#155165) 2025-10-25 22:10:36 +02:00
dependabot[bot]
16eb8315ee Bump actions/upload-artifact from 4.6.2 to 5.0.0 (#155137)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-25 22:03:04 +02:00
dependabot[bot]
2c3d65b461 Bump actions/download-artifact from 5.0.0 to 6.0.0 (#155138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-25 22:02:56 +02:00
dependabot[bot]
7e938f4f13 Bump github/codeql-action from 4.30.9 to 4.31.0 (#155139)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-25 21:56:38 +02:00
G Johansson
c5c4cf0284 Fix double reloading in axis (#155144) 2025-10-25 21:56:22 +02:00
Maciej Bieniek
68c38ac047 Improve client mock for Brother tests (#155037) 2025-10-25 21:51:36 +02:00
hanwg
f5a6fa8be1 Bump python-telegram-bot to 22.5 (#155134) 2025-10-25 21:49:47 +02:00
Maciej Bieniek
3c751918fd Catch ConnectionResetError when updating data in Cert expiry integration (#155149) 2025-10-25 21:49:09 +02:00
Erwin Douna
a3c8760b3f Portainer bump 1.0.8 (#155161) 2025-10-25 20:16:15 +02:00
Maciej Bieniek
7bceaf74be Support reconfigure flow in NextDNS integration (#154936) 2025-10-25 19:13:58 +02:00
Shai Ungar
750f06327a Bump israel-rail-api to 0.1.4 (#155153) 2025-10-25 17:03:14 +02:00
Maciej Bieniek
98bffdb9d3 Bump aioshelly to version 13.15.0 (#155150) 2025-10-25 16:20:04 +02:00
G Johansson
174b0f7c01 Use async_update_and_abort in mqtt (#155140) 2025-10-25 14:41:13 +02:00
Franck Nijhof
c960bd2845 2025.10.4 (#155109) 2025-10-24 22:02:18 +02:00
Franck Nijhof
5679ab0f86 Bump version to 2025.10.4 2025-10-24 19:24:31 +00:00
G Johansson
2761dcbc48 Bump holidays to 0.83 (#155107) 2025-10-24 19:24:10 +00:00
Sarah Seidman
3a00d96571 Bump pydroplet version to 2.3.4 (#155103) 2025-10-24 19:24:08 +00:00
Álvaro Fernández Rojas
c86ad896b8 Update aioairzone to v1.0.2 (#155088) 2025-10-24 19:24:07 +00:00
MichaelMKKelly
9c1d8747be Move URL out of system_bridge strings.json (#155067) 2025-10-24 19:24:05 +00:00
Manu
4a003114bd Improve migration to Uptime Kuma v2.0.0 (#155055) 2025-10-24 19:24:04 +00:00
Jordan Harvey
dcc3f14b1f Add shared BleakScanner to probe_plus (#155051) 2025-10-24 19:24:02 +00:00
Andre Lengwenus
7687d5ea48 Add SensorDeviceClass and unit for LCN humidity sensor. (#155044) 2025-10-24 19:24:01 +00:00
Erwin Douna
27cc3c838a Lametric remove translatable URL (#154991) 2025-10-24 19:24:00 +00:00
Marc Mueller
619cb91839 Remove async-modbus exception from hassfest requirements check (#154988) 2025-10-24 19:23:16 +00:00
wimb0
5e5e130d4e Fix BrowseError import in yamaha_musiccast media_player.py (#154980) 2025-10-24 19:21:53 +00:00
Matrix
f6ac23cc58 YoLink remove unsupported remoters (#154918) 2025-10-24 19:21:52 +00:00
Simone Chemelli
244b6437b2 Bump aioamazondevices to 6.4.6 (#154865) 2025-10-24 19:21:50 +00:00
Kinachi249
5dc271b201 Bump PyCync to 0.4.2 (#154856) 2025-10-24 19:21:49 +00:00
Manu
531cc3e1ce Bump bring-api to v1.1.1 (#154854) 2025-10-24 19:21:47 +00:00
cdnninja
ed7c3cb339 vesync show fan speed for smart tower fans (#154842) 2025-10-24 19:20:35 +00:00
Andrew Jackson
16e11ed801 Move url out of nightscout strings and change to field descriptions (#154812)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-24 19:17:34 +00:00
tronikos
32a7bf4dbb Bump opower to 0.15.8 (#154811) 2025-10-24 19:17:33 +00:00
Marc Mueller
856c99dc22 Remove opower violation from hassfest requirements check (#154797) 2025-10-24 19:17:31 +00:00
Andrew Jackson
a50b00b3c2 Move url out of Flume strings.json (#154787) 2025-10-24 19:17:30 +00:00
Andrew Jackson
1df8b1063b Move url out of rachio strings.json (#154781) 2025-10-24 19:17:29 +00:00
Andrew Jackson
32cd4364f6 Move url out of motionblinds strings.json (#154777) 2025-10-24 19:17:27 +00:00
Andrew Jackson
0828a842a5 Move url out of orsoenergy strings.json (#154776) 2025-10-24 19:17:26 +00:00
Andrew Jackson
f63a527a01 Move url out of starline strings.json (#154773) 2025-10-24 19:17:25 +00:00
Jan Bouwhuis
254a9ecc25 Move URLs out of strings.json for auth (#154769) 2025-10-24 19:17:23 +00:00
Andrew Jackson
a518907b09 Move url out of sensorpush_cloud strings.json (#154768) 2025-10-24 19:17:22 +00:00
Andrew Jackson
cd85699151 Move url out of simplisafe strings (#154762) 2025-10-24 19:17:20 +00:00
Andrew Jackson
f49dfbd459 Move URL out of TheThingsNetwork strings.json (#154760) 2025-10-24 19:17:19 +00:00
Andrew Jackson
3ed70bb751 Move URL out of Tomorrow.io strings.json (#154759) 2025-10-24 19:17:18 +00:00
ElectricSteve
b4b1065737 Fix pterodactyl server config link (#154758) 2025-10-24 19:17:16 +00:00
Maciej Bieniek
7267c3c04e Fix units for Shelly TopAC EVE01-11 sensors (#154740) 2025-10-24 19:17:15 +00:00
Brett Adams
6ac4d2dd59 Handle location scope in Tesla Fleet vehicle coordinator (#154731) 2025-10-24 19:17:13 +00:00
Anuj Soni
03abd5d277 Moved non-translatable elements out of strings.json for nuki (#154682)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-10-24 19:17:12 +00:00
Akanksha
66bb0db08b Move translatable URL out of strings.json for airnow integration (#154557)
Co-authored-by: jbouwh <jan@jbsoft.nl>
2025-10-24 19:17:10 +00:00
Jordan Harvey
56ae579e83 Bump pyprobeplus to 1.1.1 (#154523) 2025-10-24 19:17:09 +00:00
Mick Vleeshouwer
add1915b8a Improve error message for unsupported hardware in Overkiz (#154314) 2025-10-24 19:17:07 +00:00
Vincent Wolsink
18ef4af8d0 Return default temp range if API responds 0 in Huum. (#153871) 2025-10-24 19:17:06 +00:00
Alec
3c6788212f Increase connect and configuration time for rfxtrx (#153834)
Increase the allowed time for connection and configuration. Some devices take a long time to respond to configuration changes and this time is counted for both network and configuration of the device.
2025-10-24 19:17:04 +00:00
Brett Adams
dbd8b1bc19 Fix history coordinator in Tesla Fleet and Teslemetry (#153068)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-10-24 19:17:03 +00:00
Thomas55555
d135f1c110 Bump aioautomower to v2.3.1 (#151795) 2025-10-24 19:17:02 +00:00
Franck Nijhof
bb98ed6633 2025.10.3 (#154718) 2025-10-17 23:14:01 +02:00
Franck Nijhof
59dace572a Bump version to 2025.10.3 2025-10-17 20:35:30 +00:00
cdnninja
735cf36a5b Bump pyvesync version to 3.1.2 (#154650)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-17 20:34:48 +00:00
tstabrawa
90b0f50b8f Move URL out of Nuheat strings.json (#154580) 2025-10-17 20:34:47 +00:00
Simone Chemelli
e731c07b77 Bump aioamazondevices to 6.4.4 (#154538) 2025-10-17 20:34:46 +00:00
Whitney Young
2c75635e95 OpenUV: Fix update by skipping when protection window is null (#154487) 2025-10-17 20:34:45 +00:00
Anuj Soni
1f031695c2 Move translatable URLs out of strings.json for isy994 (#154464) 2025-10-17 20:34:43 +00:00
Michel van de Wetering
fb279212a9 Add missinglong_press entry for trigger_type in strings.json for Hue (#154437) 2025-10-17 20:34:42 +00:00
DannyS95
45869523d0 Move igloohome API access URL into constant placeholders (#154430) 2025-10-17 20:34:41 +00:00
puddly
a753926f22 Use async_schedule_reload instead of async_reload for ZHA (#154397) 2025-10-17 20:34:40 +00:00
Simone Chemelli
dc874ff53a Bump aiocomelit to 1.1.2 (#154393) 2025-10-17 20:34:38 +00:00
Renat Sibgatulin
3ef6865708 Bump aioairq to 0.4.7 (#154386) 2025-10-17 20:34:37 +00:00
Anuj Soni
7f1989f9f2 Move translatable URLs out of strings.json for huawei lte (#154368)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-17 20:34:36 +00:00
epenet
97e338c760 Move URL out of sfr_box strings.json (#154364)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-17 20:34:35 +00:00
wollew
101679c17d update pysqueezebox lib to 0.13.0 (#154358) 2025-10-17 20:34:33 +00:00
tronikos
bc784c356e Bump opower to 0.15.7 (#154351) 2025-10-17 20:34:32 +00:00
J. Nick Koston
556cc57d8b Fix Bluetooth discovery for devices with alternating advertisement names (#154347) 2025-10-17 20:34:31 +00:00
Oliver Gründel
eef6e96a93 Move developer url out of strings.json for coinbase setup flow (#154339) 2025-10-17 20:34:30 +00:00
Shai Ungar
56d237af7f Move URLs out of SABnzbd strings.json (#154333)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-17 20:34:29 +00:00
Oliver Gründel
e5d1902d2a Move Ecobee authorization URL out of strings.json (#154332) 2025-10-17 20:34:27 +00:00
Yevhenii Vaskivskyi
a9a203678e AsusWRT: Pass only online clients to the device list from the API (#154322) 2025-10-17 20:34:26 +00:00
Mick Vleeshouwer
7f6237cc63 Move URL out of Overkiz Config Flow descriptions (#154315) 2025-10-17 20:34:25 +00:00
Simone Chemelli
5468e691ca Bump aioamazondevices to 6.4.3 (#154293) 2025-10-17 20:34:23 +00:00
Jan-Philipp Benecke
67cbbc3522 Move Electricity Maps url out of strings.json (#154284) 2025-10-17 20:33:17 +00:00
Dan Schafer
504da54c11 Update Snoo strings.json to include weaning_baseline (#154268) 2025-10-17 20:31:13 +00:00
Jordan Harvey
cdda2ef5c8 Bump pyprobeplus to 1.1.0 (#154265) 2025-10-17 20:31:12 +00:00
Jan Bouwhuis
f405f9eb4b Fix home wiziard total increasing sensors returning 0 (#154264) 2025-10-17 20:31:10 +00:00
Manu
634f71835a Add description placeholders to pyLoad config flow (#154254) 2025-10-17 20:31:09 +00:00
Manu
49bfb01fac Add description placeholders in Uptime Kuma config flow (#154252)
Signed-off-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com>
2025-10-17 20:31:08 +00:00
Joakim Plate
ad8f7fdcab Move url like strings to placeholders for nibe (#154249) 2025-10-17 20:31:07 +00:00
J. Nick Koston
f82ec81062 Fix Yale integration to handle unavailable OAuth implementation at startup (#154245) 2025-10-17 20:31:05 +00:00
J. Nick Koston
03b0842a01 Fix August integration to handle unavailable OAuth implementation at startup (#154244) 2025-10-17 20:31:04 +00:00
Christopher Fenner
13e5cb5cc8 Remove URL from ViCare strings.json (#154243) 2025-10-17 20:31:03 +00:00
Shay Levy
f18cdaf4d8 Move URL out of Switcher strings.json (#154240) 2025-10-17 20:31:02 +00:00
Andrew Jackson
5b3bca1426 Move URL out of Mastodon strings.json (#154231) 2025-10-17 20:31:01 +00:00
Andrew Jackson
d812e9d43c Move URL out of Mealie strings.json (#154230) 2025-10-17 20:30:59 +00:00
Simone Chemelli
fa1071b221 Bump aioamazondevices to 6.4.1 (#154228) 2025-10-17 20:30:58 +00:00
Paul Bottein
e48c2c6c0b Bump frontend 20251001.4 (#154218) 2025-10-17 20:23:01 +00:00
Yvan13120
bddd4100c0 Fix state class for Overkiz water consumption (#154164) 2025-10-17 20:23:00 +00:00
srirams
70d8df2e95 Remove redudant state write in Smart Meter Texas (#154126) 2025-10-17 20:22:58 +00:00
Lennart Coopmans
08b3dd0173 PushSafer: Handle empty data section properly (#154109) 2025-10-17 20:22:57 +00:00
Magnus
6723a7c4e1 Bump aioasuswrt to 1.5.1 (#153209) 2025-10-17 20:22:55 +00:00
Franck Nijhof
40d7f2a89e 2025.10.2 (#154181) 2025-10-10 23:19:19 +02:00
Shay Levy
13b717e2da Fix shelly remove orphaned entities (#154182)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-10 22:46:30 +02:00
Franck Nijhof
5fcfd3ad84 Bump version to 2025.10.2 2025-10-10 20:29:17 +00:00
Shay Levy
324a7b5443 Fix Shelly RPC cover update when the device is not initialized (#154159) 2025-10-10 20:27:30 +00:00
Robert Resch
491ae8f72c Bump deebot-client to 15.1.0 (#154154) 2025-10-10 20:23:10 +00:00
Justus
259247892f IOmeter bump version v0.2.0 (#154150) 2025-10-10 20:23:09 +00:00
Bram Kragten
caeda0ef64 Update frontend to 20251001.2 (#154143) 2025-10-10 20:23:08 +00:00
Paul Bottein
df35c535e4 Add missing entity category and icons for smlight integration (#154131) 2025-10-10 20:23:07 +00:00
Paulus Schoutsen
f93b9e0ed0 Z-Wave: ESPHome discovery to update all options (#154113) 2025-10-10 20:23:05 +00:00
peteS-UK
48a3372cf2 Fix for multiple Lyrion Music Server on a single Home Assistant server for Squeezebox (#154081) 2025-10-10 20:23:04 +00:00
Maciej Bieniek
d84fd72428 Bump brother to version 5.1.1 (#154080) 2025-10-10 20:23:03 +00:00
Simone Chemelli
e8cb386962 Bump aioamazondevices to 6.4.0 (#154071) 2025-10-10 20:23:02 +00:00
epenet
5ac726703c Filter out invalid Renault vehicles (#154070)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-10 20:23:00 +00:00
Joost Lekkerkerker
688649a799 Don't mark ZHA coordinator as via_device with itself (#154004) 2025-10-10 20:17:07 +00:00
Artur Pragacz
c5359ade3e Fix empty llm api list in chat log (#153996) 2025-10-10 20:17:05 +00:00
Michael Davie
4e60dedc1b Bump env-canada to 0.11.3 (#153967) 2025-10-10 20:17:04 +00:00
Maciej Bieniek
221d74f83a Fix update interval for AccuWeather hourly forecast (#153957) 2025-10-10 20:17:02 +00:00
G Johansson
fbbb3d6415 Bump holidays to 0.82 (#153952) 2025-10-10 20:17:01 +00:00
Josef Zweck
8297019011 Bump pylamarzocco to 2.1.2 (#153950) 2025-10-10 20:16:59 +00:00
TheJulianJES
61715dcff3 Adjust OTBR config entry name for ZBT-2 (#153940) 2025-10-10 20:16:58 +00:00
TheJulianJES
32b822ee99 Fix HA hardware configuration message for Thread without HAOS (#153933) 2025-10-10 20:16:56 +00:00
Fabien Kleinbourg
e6c2e0ad80 sharkiq dependency bump to 1.4.2 (#153931) 2025-10-10 20:16:55 +00:00
TheJulianJES
1314427dc5 Do not auto-set up ZHA zeroconf discoveries during onboarding (#153914) 2025-10-10 20:16:53 +00:00
Tom Matheussen
bf499a45f7 Add missing translation string for Satel Integra subentry type (#153905) 2025-10-10 20:16:52 +00:00
Christopher Fenner
b955e22628 fix typo in icon assignment of AccuWeather integration (#153890) 2025-10-10 20:16:50 +00:00
Simone Chemelli
1b222ff5fd Fix restore cover state for Comelit SimpleHome (#153887) 2025-10-10 20:16:49 +00:00
derytive
f0510e703f Add plate_count for Miele KM7575 (#153868) 2025-10-10 19:36:03 +00:00
G Johansson
cbe3956e15 Handle timeout errors gracefully in Nord Pool services (#153856) 2025-10-10 19:36:01 +00:00
Aaron Bach
4588e9da8d Limit SimpliSafe websocket connection attempts during startup (#153853)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-10 19:36:00 +00:00
Simone Chemelli
5445890fdf Bump aiocomelit to 1.1.1 (#153843) 2025-10-10 19:35:59 +00:00
Simone Chemelli
9b49f77f86 Fix PIN validation for Comelit SimpleHome (#153840) 2025-10-10 19:35:57 +00:00
Petro31
566c8fb786 Fix delay_on and auto_off with multiple triggers (#153839) 2025-10-10 19:35:56 +00:00
Joost Lekkerkerker
b36150c213 Add motion presets to SmartThings AC (#153830) 2025-10-10 19:35:54 +00:00
Joost Lekkerkerker
809070d2ad Catch update exception in AirGradient (#153828) 2025-10-10 19:35:53 +00:00
Joost Lekkerkerker
f4339dc031 Bump pySmartThings to 3.3.1 (#153826) 2025-10-10 19:35:51 +00:00
epenet
f3b37d24b0 Fix Tuya cover position when only control is available (#153803) 2025-10-10 19:35:50 +00:00
Paulus Schoutsen
4c8348caa7 Handle ESPHome discoveries with uninitialized Z-Wave antennas (#153790) 2025-10-10 19:35:49 +00:00
cdnninja
b9e7c102ea vesync correct fan set modes (#153761) 2025-10-10 19:35:47 +00:00
Simone Chemelli
69d9fa89b7 Remove stale entities from Alexa Devices (#153759) 2025-10-10 19:35:46 +00:00
Simone Chemelli
6f3f5a5ec1 Bump aioamazondevices to 6.2.9 (#153756) 2025-10-10 19:35:44 +00:00
Simone Chemelli
5ecfeca90a Fix sensors availability check for Alexa Devices (#153743) 2025-10-10 19:35:43 +00:00
Sander Jochems
00e0570fd4 Upgrade python-melcloud to 0.1.2 (#153742) 2025-10-10 19:35:41 +00:00
Øyvind Matheson Wergeland
5a5b94f3af Synology DSM: Don't reinitialize API during configuration (#153739) 2025-10-10 19:35:40 +00:00
Maciej Bieniek
34f00d9b33 Align Shelly presencezone entity to the new API/firmware (#153737) 2025-10-10 19:35:39 +00:00
Tom
4cabc5b368 Bump airOS to 0.5.5 using formdata for v6 firmware (#153736) 2025-10-10 19:35:37 +00:00
tronikos
4045125422 Fix missing google_assistant_sdk.send_text_command (#153735) 2025-10-10 19:35:36 +00:00
Fredrik Erlandsson
d7393af76f Version bump pydaikin to 2.17.1 (#153726) 2025-10-10 19:35:34 +00:00
Fredrik Erlandsson
ad41386b27 Version bump pydaikin to 2.17.0 (#153718) 2025-10-10 19:35:33 +00:00
tronikos
62d17ea20c Bump opower to 0.15.6 (#153714) 2025-10-10 19:35:31 +00:00
peetersch
c4954731d0 Modbus Fix message_wait_milliseconds is no longer applied (#153709) 2025-10-10 19:35:30 +00:00
cdnninja
647723d3f0 Bump pyvesync to 3.1.0 (#153693) 2025-10-10 19:35:28 +00:00
Christopher Fenner
51c500e22c Fix ViCare pressure sensors missing unit of measurement (#153691) 2025-10-10 19:35:26 +00:00
Denis Shulyaka
f6fc13c1f2 Gemini: Use default model instead of recommended where applicable (#153676) 2025-10-10 19:35:25 +00:00
Jan Bouwhuis
0009a7a042 Fix MQTT Lock state reset to unknown when a reset payload is received (#153647) 2025-10-10 19:35:24 +00:00
Luke Lashley
a3d1aa28e7 Switch Roborock to v4 of the code login api (#153593) 2025-10-10 19:35:22 +00:00
Simone Chemelli
9f53eb9b76 Bump aioamazondevices to 6.2.8 (#153592) 2025-10-10 19:35:21 +00:00
Luke Lashley
f53a205ff3 Bump python-roborock to 2.50.2 (#153561) 2025-10-10 19:35:19 +00:00
NANI
d08517c3df Updated VRM client and accounted for missing forecasts (#153464) 2025-10-10 19:35:18 +00:00
Kinachi249
d7398a44a1 Bump PyCync to 0.4.1 (#153401) 2025-10-10 19:35:17 +00:00
Aidan Timson
9acfc0cb88 Fix power device classes for system bridge (#153201) 2025-10-10 19:35:15 +00:00
Hessel
1b3d21523a Wallbox fix Rate Limit issue for multiple chargers (#153074) 2025-10-10 19:35:14 +00:00
puddly
1d407d1326 Prevent reloading the ZHA integration while adapter firmware is being updated (#152626) 2025-10-10 19:35:12 +00:00
Franck Nijhof
013346cead 2025.10.1 (#153582) 2025-10-03 20:08:44 +02:00
Franck Nijhof
5abaabc9da Bump version to 2025.10.1 2025-10-03 17:26:37 +00:00
Paulus Schoutsen
32481312c3 When discovering a Z-Wave adapter, always configure add-on in config flow (#153575) 2025-10-03 17:26:16 +00:00
Paulus Schoutsen
bdc9eb37d3 Z-Wave to support migrating from USB to socket with same home ID (#153522) 2025-10-03 17:26:15 +00:00
Abílio Costa
e0afcbc02b Debounce updates in Idasen Desk (#153503) 2025-10-03 17:26:13 +00:00
puddly
cd56a6a98d Bump universal-silabs-flasher to 0.0.35 (#153500) 2025-10-03 17:26:11 +00:00
cdnninja
9d85893bbb Fix VeSync zero fan speed handling (#153493)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-10-03 17:26:10 +00:00
starkillerOG
9e8a70225f Bump reolink-aio to 0.16.1 (#153489) 2025-10-03 17:26:08 +00:00
Daniel Hjelseth Høyer
96ec795d5e Bump pyTibber to 0.32.2 (#153484) 2025-10-03 17:26:07 +00:00
Josef Zweck
65b796070d Fix missing parameter pass in onedrive (#153478) 2025-10-03 17:26:05 +00:00
Aidan Timson
32994812e5 Update OVOEnergy to 3.0.1 (#153476) 2025-10-03 17:26:04 +00:00
G Johansson
66ff9d63a3 Fix next event in workday calendar (#153465) 2025-10-03 17:26:02 +00:00
Joost Lekkerkerker
b2a63d4996 Add translation for turbo fan mode in SmartThings (#153445)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-03 17:26:00 +00:00
puddly
f9f37b7f2a Disable baudrate bootloader reset for ZBT-2 (#153443) 2025-10-03 17:25:59 +00:00
Stefan Agner
7bdd9dd38a Update Home Assistant base image to 2025.10.0 (#153441) 2025-10-03 17:25:58 +00:00
Joost Lekkerkerker
1e8aae0a89 Fix missing powerconsumptionreport in Smartthings (#153438) 2025-10-03 17:25:56 +00:00
Aidan Timson
cf668e9dc2 Add missing translation for media browser default title (#153430)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-10-03 17:25:55 +00:00
Norbert Rittel
2e91c8700f Fix sentence-casing in user-facing strings of slack (#153427) 2025-10-03 17:25:53 +00:00
J. Nick Koston
9d14627daa Bump aiohomekit to 3.2.19 (#153423) 2025-10-03 17:25:52 +00:00
TheJulianJES
73b8283748 Fix Z-Wave RGB light turn on causing rare ZeroDivisionError (#153422) 2025-10-03 17:25:50 +00:00
Manu
edeaaa2e63 Update markdown field description in ntfy integration (#153421) 2025-10-03 17:25:49 +00:00
Tom Matheussen
d26dd8fc39 Fix Satel Integra creating new binary sensors on YAML import (#153419) 2025-10-03 17:25:47 +00:00
Denis Shulyaka
34640ea735 Disable thinking for unsupported gemini models (#153415) 2025-10-03 17:25:46 +00:00
Erwin Douna
46a2e21ef0 Bump pyportainer 1.0.3 (#153413) 2025-10-03 17:25:45 +00:00
Erwin Douna
508af53e72 Bump pyportainer 1.0.2 (#153326) 2025-10-03 17:25:43 +00:00
Josef Zweck
5f7440608c Increase onedrive upload chunk size (#153406) 2025-10-03 17:22:10 +00:00
Michael J. Kidd
0d1aa38a26 Pushover: Handle empty data section properly (#153397) 2025-10-03 17:22:08 +00:00
Luke Lashley
929f8c148a Bump python-roborock to 2.49.1 (#153396) 2025-10-03 17:22:07 +00:00
Joakim Plate
92db1f5a04 Correct blocking update in ToGrill with lack of notifications (#153387) 2025-10-03 17:22:05 +00:00
starkillerOG
e66b5ce0bf Add Roborock mop intensity translations (#153380) 2025-10-03 17:22:03 +00:00
Michael
1e17150e9f Explicit pass in the config entry to coordinator in airtouch4 (#153361)
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-03 17:22:02 +00:00
Michael
792902de3d Set config entry to None in ProxmoxVE (#153357) 2025-10-03 17:22:00 +00:00
Andre Lengwenus
04d78c3dd5 Explicitly check for None in raw value processing of modbus (#153352) 2025-10-03 17:21:59 +00:00
G Johansson
5c8d5bfb84 Fix Nord Pool 15 minute interval (#153350) 2025-10-03 17:21:57 +00:00
puddly
99bff31869 Do not reset the adapter twice during ZHA options flow migration (#153345) 2025-10-03 17:21:56 +00:00
Stefan Agner
d949119fb0 Bump aiohasupervisor to 0.3.3 (#153344) 2025-10-03 17:21:54 +00:00
Tom
e7b737ece5 Bump airOS module for alternative login url (#153317) 2025-10-03 17:21:52 +00:00
Tom
fb8ddac2e8 Bump airOS dependency (#153065) 2025-10-03 17:21:51 +00:00
159 changed files with 5656 additions and 1583 deletions

View File

@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: translations
path: translations.tar.gz
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -464,7 +464,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations

View File

@@ -535,7 +535,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -867,7 +867,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
category: "/language:python"

View File

@@ -92,7 +92,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: env_file
path: ./.env_file
@@ -150,7 +150,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: env_file

2
CODEOWNERS generated
View File

@@ -1543,6 +1543,8 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali_center/ @niracler
/tests/components/sunricher_dali_center/ @niracler
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

View File

@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
return self.async_update_and_abort(
self._get_reauth_entry(), data_updates=config
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
return self.async_update_and_abort(
self._get_reconfigure_entry(), data_updates=config
)
self._abort_if_unique_id_configured()
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info[CONF_MAC])
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
)
self.context.update(

View File

@@ -14,6 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from .const import DEFAULT_PORT, DOMAIN
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -49,6 +50,8 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
self._errors[CONF_HOST] = "connection_timeout"
except ConnectionRefused:
self._errors[CONF_HOST] = "connection_refused"
except ConnectionReset:
self._errors[CONF_HOST] = "connection_reset"
except ValidationFailure:
return True
else:

View File

@@ -25,3 +25,7 @@ class ConnectionTimeout(TemporaryFailure):
class ConnectionRefused(TemporaryFailure):
"""Network connection refused."""
class ConnectionReset(TemporaryFailure):
"""Network connection reset."""

View File

@@ -13,6 +13,7 @@ from homeassistant.util.ssl import get_default_context
from .const import TIMEOUT
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -58,6 +59,8 @@ async def get_cert_expiry_timestamp(
raise ConnectionRefused(
f"Connection refused by server: {hostname}:{port}"
) from err
except ConnectionResetError as err:
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
except ssl.CertificateError as err:
raise ValidationFailure(err.verify_message) from err
except ssl.SSLError as err:

View File

@@ -14,7 +14,8 @@
"error": {
"resolve_failed": "This host cannot be resolved",
"connection_timeout": "Timeout when connecting to this host",
"connection_refused": "Connection refused when connecting to host"
"connection_refused": "Connection refused when connecting to host",
"connection_reset": "Connection reset when connecting to host"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

View File

@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .const import DEFAULT_PORT
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -43,13 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
entry.data[CONF_HOST],
entry.data.get(CONF_PORT, DEFAULT_PORT),
entry.data[CONF_PIN],
entry.data.get(CONF_VEDO_PIN),
session,
)
platforms = list(BRIDGE_PLATFORMS)
# Add VEDO platforms if vedo_pin is configured
if entry.data.get(CONF_VEDO_PIN):
platforms.extend(VEDO_PLATFORMS)
platforms = BRIDGE_PLATFORMS
else:
coordinator = ComelitVedoSystem(
hass,
@@ -74,10 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
"""Unload a config entry."""
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
platforms = list(BRIDGE_PLATFORMS)
# Add VEDO platforms if vedo_pin was configured
if entry.data.get(CONF_VEDO_PIN):
platforms.extend(VEDO_PLATFORMS)
platforms = BRIDGE_PLATFORMS
else:
platforms = VEDO_PLATFORMS

View File

@@ -6,7 +6,7 @@ import logging
from typing import cast
from aiocomelit.api import ComelitVedoAreaObject
from aiocomelit.const import BRIDGE, AlarmAreaState
from aiocomelit.const import AlarmAreaState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -14,13 +14,11 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .utils import DeviceType, alarm_device_listener
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -58,34 +56,12 @@ async def async_setup_entry(
) -> None:
"""Set up the Comelit VEDO system alarm control panel devices."""
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
# Only setup if bridge has VEDO alarm enabled
if not coordinator.vedo_pin:
return
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitBridgeAlarmEntity(coordinator, device, config_entry.entry_id)
for device in (coordinator.alarm_data or {})
.get("alarm_areas", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_entities, "alarm_areas")
)
else:
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
)
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_areas"].values()
)
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
@@ -195,133 +171,3 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)
class ComelitBridgeAlarmEntity(
CoordinatorEntity[ComelitSerialBridge], AlarmControlPanelEntity
):
"""Representation of a VEDO alarm panel on a Serial Bridge."""
_attr_has_entity_name = True
_attr_name = None
_attr_code_format = CodeFormat.NUMBER
_attr_code_arm_required = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_HOME
)
def __init__(
self,
coordinator: ComelitSerialBridge,
area: ComelitVedoAreaObject,
config_entry_entry_id: str,
) -> None:
"""Initialize the alarm panel."""
self._area_index = area.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{area.index}"
self._attr_device_info = coordinator.platform_device_info(area, "area")
if area.p2:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT
@property
def _area(self) -> ComelitVedoAreaObject:
"""Return area object."""
if self.coordinator.alarm_data:
return self.coordinator.alarm_data["alarm_areas"][self._area_index]
# Return a default area object if no alarm data
return ComelitVedoAreaObject(
index=self._area_index,
name="Unknown",
p1=False,
p2=False,
ready=False,
armed=0,
alarm=False,
alarm_memory=False,
sabotage=False,
anomaly=False,
in_time=False,
out_time=False,
human_status=AlarmAreaState.UNKNOWN,
)
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if not self.coordinator.alarm_data:
return False
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
return False
return super().available
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the alarm."""
_LOGGER.debug(
"Area %s status is: %s. Armed is %s",
self._area.name,
self._area.human_status,
self._area.armed,
)
if self._area.human_status == AlarmAreaState.ARMED:
if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]:
return AlarmControlPanelState.ARMED_AWAY
if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]:
return AlarmControlPanelState.ARMED_NIGHT
return AlarmControlPanelState.ARMED_HOME
return {
AlarmAreaState.DISARMED: AlarmControlPanelState.DISARMED,
AlarmAreaState.ENTRY_DELAY: AlarmControlPanelState.DISARMING,
AlarmAreaState.EXIT_DELAY: AlarmControlPanelState.ARMING,
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
}.get(self._area.human_status)
async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
"""Update state after action."""
self._area.human_status = area_state
self._area.armed = armed
await self.async_update_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
if code != str(self.coordinator.vedo_pin):
return
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE]
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
)
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]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
)
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]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
)
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]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)

View File

@@ -5,19 +5,17 @@ from __future__ import annotations
from typing import cast
from aiocomelit import ComelitVedoZoneObject
from aiocomelit.const import BRIDGE
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .utils import DeviceType, alarm_device_listener, new_device_listener
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -30,47 +28,21 @@ async def async_setup_entry(
) -> None:
"""Set up Comelit VEDO presence sensors."""
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
# Only setup if bridge has VEDO alarm enabled
if not coordinator.vedo_pin:
return
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBridgeBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in (coordinator.alarm_data or {})
.get("alarm_zones", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
else:
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
"""Add entities for new monitors."""
entities = [
ComelitVedoBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in coordinator.data["alarm_zones"].values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
config_entry.async_on_unload(
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
)
class ComelitVedoBinarySensorEntity(
@@ -101,41 +73,3 @@ class ComelitVedoBinarySensorEntity(
return (
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
)
class ComelitVedoBridgeBinarySensorEntity(
CoordinatorEntity[ComelitSerialBridge], BinarySensorEntity
):
"""VEDO sensor device on a Serial Bridge."""
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(
self,
coordinator: ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
) -> None:
"""Init sensor entity."""
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
@property
def available(self) -> bool:
"""Sensor availability."""
return self.coordinator.alarm_data is not None
@property
def is_on(self) -> bool:
"""Presence detected."""
if not self.coordinator.alarm_data:
return False
return (
self.coordinator.alarm_data["alarm_zones"][self._zone_index].status_api
== "0001"
)

View File

@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
@@ -34,7 +34,6 @@ USER_SCHEMA = vol.Schema(
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
@@ -43,7 +42,6 @@ STEP_RECONFIGURE = vol.Schema(
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
vol.Optional(CONF_VEDO_PIN): cv.string,
}
)
@@ -81,27 +79,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
finally:
await api.logout()
# Validate VEDO PIN if provided and device type is BRIDGE
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
raise InvalidVedoPin
# Verify VEDO is enabled with the provided PIN
try:
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
raise InvalidVedoAuth
except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
raise CannotConnect(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except aiocomelit_exceptions.CannotAuthenticate:
raise InvalidVedoAuth(
translation_domain=DOMAIN,
translation_key="invalid_vedo_auth",
) from None
return {"title": data[CONF_HOST]}
@@ -129,10 +106,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -214,38 +187,19 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
try:
data_to_validate = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
if CONF_VEDO_PIN in user_input:
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
await validate_input(self.hass, data_to_validate)
await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except InvalidVedoPin:
errors["base"] = "invalid_vedo_pin"
except InvalidVedoAuth:
errors["base"] = "invalid_vedo_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
if CONF_VEDO_PIN in user_input:
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=data_updates
reconfigure_entry, data_updates={CONF_HOST: updated_host}
)
return self.async_show_form(
@@ -265,11 +219,3 @@ class InvalidAuth(HomeAssistantError):
class InvalidPin(HomeAssistantError):
"""Error to indicate an invalid pin."""
class InvalidVedoPin(HomeAssistantError):
"""Error to indicate an invalid VEDO pin."""
class InvalidVedoAuth(HomeAssistantError):
"""Error to indicate VEDO authentication failed."""

View File

@@ -9,7 +9,6 @@ _LOGGER = logging.getLogger(__package__)
DOMAIN = "comelit"
DEFAULT_PORT = 80
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
CONF_VEDO_PIN = "vedo_pin"
SCAN_INTERVAL = 5

View File

@@ -154,8 +154,6 @@ class ComelitSerialBridge(
_hw_version = "20003101"
api: ComeliteSerialBridgeApi
vedo_pin: str | None
alarm_data: AlarmDataObject | None = None
def __init__(
self,
@@ -164,49 +162,25 @@ class ComelitSerialBridge(
host: str,
port: int,
pin: str,
vedo_pin: str | None,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
self.vedo_pin = vedo_pin
super().__init__(hass, entry, BRIDGE, host)
async def _async_update_system_data(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data."""
devices = await self.api.get_all_devices()
data = await self.api.get_all_devices()
if self.data:
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
await self._async_remove_stale_devices(
self.data[dev_type], devices[dev_type], dev_type
self.data[dev_type], data[dev_type], dev_type
)
# Get VEDO alarm data if vedo_pin is configured
if self.vedo_pin:
try:
if await self.api.vedo_enabled(self.vedo_pin):
self.alarm_data = await self.api.get_all_areas_and_zones()
# Remove stale alarm devices
if self.alarm_data:
previous_alarm_data = getattr(
self, "_previous_alarm_data", None
)
if previous_alarm_data:
for obj_type in ("alarm_areas", "alarm_zones"):
await self._async_remove_stale_devices(
previous_alarm_data[obj_type],
self.alarm_data[obj_type],
"area" if obj_type == "alarm_areas" else "zone",
)
self._previous_alarm_data = self.alarm_data
except (CannotAuthenticate, CannotConnect, CannotRetrieveData):
_LOGGER.warning("Failed to retrieve VEDO alarm data")
return devices
return data
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .entity import ComelitBridgeBaseEntity
from .utils import DeviceType, alarm_device_listener, new_device_listener
from .utils import DeviceType, new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -83,30 +83,6 @@ async def async_setup_bridge_entry(
new_device_listener(coordinator, _add_new_entities, OTHER)
)
# Add VEDO sensors if bridge has alarm data
if coordinator.vedo_pin:
def _add_new_alarm_entities(
new_devices: list[DeviceType], dev_type: str
) -> None:
"""Add entities for new alarm zones."""
entities = [
ComelitVedoBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in (coordinator.alarm_data or {})
.get("alarm_zones", {})
.values()
if device in new_devices
]
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
alarm_device_listener(coordinator, _add_new_alarm_entities, "alarm_zones")
)
async def async_setup_vedo_entry(
hass: HomeAssistant,
@@ -203,58 +179,3 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
return None
return cast(str, status.value)
class ComelitVedoBridgeSensorEntity(
CoordinatorEntity[ComelitSerialBridge], SensorEntity
):
"""VEDO sensor device on a Serial Bridge."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ComelitSerialBridge,
zone: ComelitVedoZoneObject,
config_entry_entry_id: str,
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self.entity_description = description
@property
def _zone_object(self) -> ComelitVedoZoneObject:
"""Zone object."""
if self.coordinator.alarm_data:
return self.coordinator.alarm_data["alarm_zones"][self._zone_index]
# Return a default zone object if no alarm data
return ComelitVedoZoneObject(
index=self._zone_index,
name="Unknown",
status_api="0x000",
status=0,
human_status=AlarmZoneState.UNAVAILABLE,
)
@property
def available(self) -> bool:
"""Sensor availability."""
return (
self.coordinator.alarm_data is not None
and self._zone_object.human_status != AlarmZoneState.UNAVAILABLE
)
@property
def native_value(self) -> StateType:
"""Sensor value."""
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
return None
return cast(str, status.value)

View File

@@ -15,29 +15,25 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]",
"type": "Device type",
"vedo_pin": "VEDO alarm PIN (optional)"
"type": "Device type"
},
"data_description": {
"host": "The hostname or IP address of your Comelit device.",
"port": "The port of your Comelit device.",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"type": "The type of your Comelit device.",
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
"type": "The type of your Comelit device."
}
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
"pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]"
}
}
},
@@ -48,16 +44,12 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},

View File

@@ -158,35 +158,3 @@ def new_device_listener(
_check_devices()
return coordinator.async_add_listener(_check_devices)
def alarm_device_listener(
coordinator: ComelitBaseCoordinator,
new_devices_callback: Callable[
[list[ComelitVedoAreaObject | ComelitVedoZoneObject], str],
None,
],
data_type: str,
) -> Callable[[], None]:
"""Subscribe to coordinator updates to check for new alarm devices on bridge."""
known_devices: dict[str, list[int]] = {}
def _check_alarm_devices() -> None:
"""Check for new alarm devices and call callback with any new devices."""
# For ComelitSerialBridge with alarm_data
if not hasattr(coordinator, "alarm_data") or not coordinator.alarm_data:
return
new_devices: list[ComelitVedoAreaObject | ComelitVedoZoneObject] = []
for _id in coordinator.alarm_data[data_type]:
if _id not in (id_list := known_devices.get(data_type, [])):
known_devices.update({data_type: [*id_list, _id]})
new_devices.append(coordinator.alarm_data[data_type][_id])
if new_devices:
new_devices_callback(new_devices, data_type)
# Check for devices immediately
_check_alarm_devices()
return coordinator.async_add_listener(_check_alarm_devices)

View File

@@ -184,7 +184,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
}
},
reload_on_update=False,
)
except TimeoutError:
@@ -231,7 +232,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
},
reload_on_update=False,
)
self.context.update(
@@ -265,7 +267,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
}
},
reload_on_update=False,
)
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL

View File

@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .const import SUPPORTED_LIFESPANS
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
key=f"station_action_{action.name.lower()}",
translation_key=f"station_action_{action.name.lower()}",
)
for action in StationAction
for action in SUPPORTED_STATION_ACTIONS
)

View File

@@ -23,7 +23,11 @@ SUPPORTED_LIFESPANS = (
LifeSpan.STATION_FILTER,
)
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
SUPPORTED_STATION_ACTIONS = (
StationAction.CLEAN_BASE,
StationAction.DRY_MOP,
StationAction.EMPTY_DUSTBIN,
)
LEGACY_SUPPORTED_LIFESPANS = (
"main_brush",

View File

@@ -36,6 +36,12 @@
"reset_lifespan_round_mop": {
"default": "mdi:broom"
},
"station_action_clean_base": {
"default": "mdi:home"
},
"station_action_dry_mop": {
"default": "mdi:broom"
},
"station_action_empty_dustbin": {
"default": "mdi:delete-restore"
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
}

View File

@@ -70,6 +70,12 @@
"reset_lifespan_side_brush": {
"name": "Reset side brush lifespan"
},
"station_action_clean_base": {
"name": "Clean base"
},
"station_action_dry_mop": {
"name": "Dry mop"
},
"station_action_empty_dustbin": {
"name": "Empty dustbin"
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.6"]
"requirements": ["pyfirefly==0.1.8"]
}

View File

@@ -19,7 +19,7 @@ from .const import (
CONF_CHAT_MODEL,
CONF_RECOMMENDED,
LOGGER,
RECOMMENDED_A_TASK_MAX_TOKENS,
RECOMMENDED_AI_TASK_MAX_TOKENS,
RECOMMENDED_IMAGE_MODEL,
)
from .entity import (
@@ -80,7 +80,7 @@ class GoogleGenerativeAITaskEntity(
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.structure, default_max_tokens=RECOMMENDED_A_TASK_MAX_TOKENS
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):

View File

@@ -33,7 +33,7 @@ RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 3000
# Input 5000, output 19400 = 0.05 USD
RECOMMENDED_A_TASK_MAX_TOKENS = 19400
RECOMMENDED_AI_TASK_MAX_TOKENS = 19400
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"

View File

@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
LOGIN_INVALID_AUTH_CODE = "502"

View File

@@ -0,0 +1,162 @@
"""Number platform for Growatt."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from growattServer import GrowattV1ApiError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = (
1 # Serialize updates as inverter does not handle concurrent requests
)
@dataclass(frozen=True, kw_only=True)
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
"""Describes Growatt number entity."""
write_key: str | None = None # Parameter ID for writing (if different from api_key)
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
# Reading values returns camelCase keys, while writing requires snake_case keys.
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
GrowattNumberEntityDescription(
key="battery_charge_power_limit",
translation_key="battery_charge_power_limit",
api_key="chargePowerCommand", # Key returned by V1 API
write_key="charge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_charge_soc_limit",
translation_key="battery_charge_soc_limit",
api_key="wchargeSOCLowLimit", # Key returned by V1 API
write_key="charge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_power_limit",
translation_key="battery_discharge_power_limit",
api_key="disChargePowerCommand", # Key returned by V1 API
write_key="discharge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_soc_limit",
translation_key="battery_discharge_soc_limit",
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
write_key="discharge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Growatt number entities."""
runtime_data = entry.runtime_data
# Add number entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattNumber(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_NUMBER_TYPES
)
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
"""Representation of a Growatt number."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: GrowattNumberEntityDescription
def __init__(
self,
coordinator: GrowattCoordinator,
description: GrowattNumberEntityDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
)
@property
def native_value(self) -> int | None:
"""Return the current value of the number."""
value = self.coordinator.data.get(self.entity_description.api_key)
if value is None:
return None
return int(value)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the number."""
# Use write_key if specified, otherwise fall back to api_key
parameter_id = (
self.entity_description.write_key or self.entity_description.api_key
)
int_value = int(value)
try:
# Use V1 API to write parameter
await self.hass.async_add_executor_job(
self.coordinator.api.min_write_parameter,
self.coordinator.device_id,
parameter_id,
int_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
# If no exception was raised, the write was successful
_LOGGER.debug(
"Set parameter %s to %s",
parameter_id,
value,
)
# Update the value in coordinator data to avoid triggering an immediate
# refresh that would hit the API rate limit (5-minute polling interval)
self.coordinator.data[self.entity_description.api_key] = int_value
self.async_write_ha_state()

View File

@@ -504,6 +504,20 @@
"name": "Maximum power"
}
},
"number": {
"battery_charge_power_limit": {
"name": "Battery charge power limit"
},
"battery_charge_soc_limit": {
"name": "Battery charge SOC limit"
},
"battery_discharge_power_limit": {
"name": "Battery discharge power limit"
},
"battery_discharge_soc_limit": {
"name": "Battery discharge SOC limit"
}
},
"switch": {
"ac_charge": {
"name": "Charge from grid"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.3.0"]
"requirements": ["homematicip==2.3.1"]
}

View File

@@ -11,6 +11,7 @@ from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -71,6 +72,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def get_main_device(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> dr.DeviceEntry | None:
"""Helper function to get the main device for the config entry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
if not device_entries:
return None
# Get first device that is not a sub-device, as this is the main device in HomeWizard
# This is relevant for the P1 Meter which may create sub-devices for external utility meters
return next(
(device for device in device_entries if device.via_device_id is None), None
)
async def async_check_v2_support_and_create_issue(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> None:
@@ -79,6 +99,16 @@ async def async_check_v2_support_and_create_issue(
if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
return
title = entry.title
# Try to get the name from the device registry
# This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time
if main_device := get_main_device(hass, entry):
device_name = main_device.name_by_user or main_device.name
if device_name and entry.title != device_name:
title = f"{entry.title} ({device_name})"
async_create_issue(
hass,
DOMAIN,
@@ -88,7 +118,7 @@ async def async_check_v2_support_and_create_issue(
learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
translation_key="migrate_to_v2_api",
translation_placeholders={
"title": entry.title,
"title": title,
},
severity=IssueSeverity.WARNING,
data={"entry_id": entry.entry_id},

View File

@@ -177,7 +177,7 @@
},
"issues": {
"migrate_to_v2_api": {
"title": "Update authentication method",
"title": "Update the authentication method for {title}",
"fix_flow": {
"step": {
"confirm": {

View File

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

View File

@@ -445,7 +445,8 @@
"main_area": "Main area",
"secondary_area": "Secondary area",
"home": "Home",
"demo": "Demo"
"demo": "Demo",
"poi": "Point of interest"
}
},
"uptime": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
"iot_class": "cloud_polling",
"loggers": ["israelrailapi"],
"requirements": ["israel-rail-api==0.1.3"]
"requirements": ["israel-rail-api==0.1.4"]
}

View File

@@ -12,6 +12,7 @@ from xknx.devices import (
)
from xknx.devices.fan import FanSpeedMode
from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from xknx.remote_value.remote_value_setpoint_shift import SetpointShiftMode
from homeassistant import config_entries
from homeassistant.components.climate import (
@@ -34,13 +35,53 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import (
CONF_SYNC_STATE,
CONTROLLER_MODES,
CURRENT_HVAC_ACTIONS,
DOMAIN,
KNX_MODULE_KEY,
ClimateConf,
)
from .entity import (
KnxUiEntity,
KnxUiEntityPlatformController,
KnxYamlEntity,
_KnxEntityBase,
)
from .knx_module import KNXModule
from .schema import ClimateSchema
from .storage.const import (
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_CONTROLLER_MODE,
CONF_GA_CONTROLLER_STATUS,
CONF_GA_FAN_SPEED,
CONF_GA_FAN_SWING,
CONF_GA_FAN_SWING_HORIZONTAL,
CONF_GA_HEAT_COOL,
CONF_GA_HUMIDITY_CURRENT,
CONF_GA_ON_OFF,
CONF_GA_OP_MODE_COMFORT,
CONF_GA_OP_MODE_ECO,
CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_TEMPERATURE_CURRENT,
CONF_GA_TEMPERATURE_TARGET,
CONF_GA_VALVE,
CONF_IGNORE_AUTO_MODE,
CONF_TARGET_TEMPERATURE,
)
from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode
from .storage.util import ConfigExtractor
ATTR_COMMAND_VALUE = "command_value"
CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
@@ -53,12 +94,30 @@ async def async_setup_entry(
) -> None:
"""Set up climate(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE]
async_add_entities(
KNXClimate(knx_module, entity_config) for entity_config in config
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.CLIMATE,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiClimate,
),
)
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.CLIMATE):
entities.extend(
KnxYamlClimate(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE):
entities.extend(
KnxUiClimate(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX."""
@@ -99,8 +158,8 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
group_address_heat_cool_state=config.get(
ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS
),
operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES),
controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES),
operation_modes=config.get(ClimateConf.OPERATION_MODES),
controller_modes=config.get(ClimateConf.CONTROLLER_MODES),
)
return XknxClimate(
@@ -120,24 +179,24 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
ClimateSchema.CONF_SETPOINT_SHIFT_STATE_ADDRESS
),
setpoint_shift_mode=config.get(ClimateSchema.CONF_SETPOINT_SHIFT_MODE),
setpoint_shift_max=config[ClimateSchema.CONF_SETPOINT_SHIFT_MAX],
setpoint_shift_min=config[ClimateSchema.CONF_SETPOINT_SHIFT_MIN],
temperature_step=config[ClimateSchema.CONF_TEMPERATURE_STEP],
setpoint_shift_max=config[ClimateConf.SETPOINT_SHIFT_MAX],
setpoint_shift_min=config[ClimateConf.SETPOINT_SHIFT_MIN],
temperature_step=config[ClimateConf.TEMPERATURE_STEP],
group_address_on_off=config.get(ClimateSchema.CONF_ON_OFF_ADDRESS),
group_address_on_off_state=config.get(ClimateSchema.CONF_ON_OFF_STATE_ADDRESS),
on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT],
on_off_invert=config[ClimateConf.ON_OFF_INVERT],
group_address_active_state=config.get(ClimateSchema.CONF_ACTIVE_STATE_ADDRESS),
group_address_command_value_state=config.get(
ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS
),
min_temp=config.get(ClimateSchema.CONF_MIN_TEMP),
max_temp=config.get(ClimateSchema.CONF_MAX_TEMP),
min_temp=config.get(ClimateConf.MIN_TEMP),
max_temp=config.get(ClimateConf.MAX_TEMP),
mode=climate_mode,
group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS),
group_address_fan_speed_state=config.get(
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
),
fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
fan_speed_mode=config[ClimateConf.FAN_SPEED_MODE],
group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS),
group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS),
group_address_horizontal_swing=config.get(
@@ -152,91 +211,195 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
)
class KNXClimate(KnxYamlEntity, ClimateEntity):
def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX from UI config."""
sync_state = conf.get(CONF_SYNC_STATE)
op_modes: list[str | HVACOperationMode] = list(HVACOperationMode)
if conf.get(CONF_IGNORE_AUTO_MODE):
op_modes.remove(HVACOperationMode.AUTO)
climate_mode = XknxClimateMode(
xknx,
name=f"{name} Mode",
group_address_operation_mode=conf.get_write(CONF_GA_OPERATION_MODE),
group_address_operation_mode_state=conf.get_state_and_passive(
CONF_GA_OPERATION_MODE
),
group_address_operation_mode_comfort=conf.get_write_and_passive(
CONF_GA_OP_MODE_COMFORT
),
group_address_operation_mode_economy=conf.get_write_and_passive(
CONF_GA_OP_MODE_ECO
),
group_address_operation_mode_protection=conf.get_write_and_passive(
CONF_GA_OP_MODE_PROTECTION
),
group_address_operation_mode_standby=conf.get_write_and_passive(
CONF_GA_OP_MODE_STANDBY
),
group_address_controller_status=conf.get_write(CONF_GA_CONTROLLER_STATUS),
group_address_controller_status_state=conf.get_state_and_passive(
CONF_GA_CONTROLLER_STATUS
),
group_address_controller_mode=conf.get_write(CONF_GA_CONTROLLER_MODE),
group_address_controller_mode_state=conf.get_state_and_passive(
CONF_GA_CONTROLLER_MODE
),
group_address_heat_cool=conf.get_write(CONF_GA_HEAT_COOL),
group_address_heat_cool_state=conf.get_state_and_passive(CONF_GA_HEAT_COOL),
sync_state=sync_state,
operation_modes=op_modes,
)
sps_mode = None
if _sps_dpt := conf.get_dpt(CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT):
sps_mode = (
SetpointShiftMode.DPT6010
if _sps_dpt == ConfSetpointShiftMode.COUNT
else SetpointShiftMode.DPT9002
)
_fan_speed_dpt = conf.get_dpt(CONF_GA_FAN_SPEED)
fan_speed_mode = (
FanSpeedMode.STEP
if _fan_speed_dpt == ConfClimateFanSpeedMode.STEPS
else FanSpeedMode.PERCENT
)
return XknxClimate(
xknx,
name=name,
group_address_temperature=conf.get_state_and_passive(
CONF_GA_TEMPERATURE_CURRENT
),
group_address_target_temperature=conf.get_write(
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
),
group_address_target_temperature_state=conf.get_state_and_passive(
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
),
group_address_setpoint_shift=conf.get_write(
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
),
group_address_setpoint_shift_state=conf.get_state_and_passive(
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
),
setpoint_shift_mode=sps_mode,
setpoint_shift_max=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MAX, default=6
),
setpoint_shift_min=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MIN, default=-6
),
temperature_step=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.TEMPERATURE_STEP, default=0.1
),
group_address_on_off=conf.get_write(CONF_GA_ON_OFF),
group_address_on_off_state=conf.get_state_and_passive(CONF_GA_ON_OFF),
on_off_invert=conf.get(ClimateConf.ON_OFF_INVERT, default=False),
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
sync_state=sync_state,
min_temp=conf.get(ClimateConf.MIN_TEMP),
max_temp=conf.get(ClimateConf.MAX_TEMP),
mode=climate_mode,
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
fan_speed_mode=fan_speed_mode,
group_address_humidity_state=conf.get_state_and_passive(
CONF_GA_HUMIDITY_CURRENT
),
group_address_swing=conf.get_write(CONF_GA_FAN_SWING),
group_address_swing_state=conf.get_state_and_passive(CONF_GA_FAN_SWING),
group_address_horizontal_swing=conf.get_write(CONF_GA_FAN_SWING_HORIZONTAL),
group_address_horizontal_swing_state=conf.get_state_and_passive(
CONF_GA_FAN_SWING_HORIZONTAL
),
)
class _KnxClimate(ClimateEntity, _KnxEntityBase):
"""Representation of a KNX climate device."""
_device: XknxClimate
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "knx_climate"
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
default_hvac_mode: HVACMode
_last_hvac_mode: HVACMode
fan_zero_mode: str
_fan_modes_percentages: list[int]
def _init_from_device_config(
self,
device: XknxClimate,
default_hvac_mode: HVACMode,
fan_max_step: int,
fan_zero_mode: str,
) -> None:
"""Set attributes that depend on device config."""
self.default_hvac_mode = default_hvac_mode
# non-OFF HVAC mode to be used when turning on the device without on_off address
self._last_hvac_mode = self.default_hvac_mode
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
if self._device.supports_on_off:
if device.supports_on_off:
self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
if (
self._device.mode is not None
and len(self._device.mode.controller_modes) >= 2
and HVACControllerMode.OFF in self._device.mode.controller_modes
device.mode is not None
and len(device.mode.controller_modes) >= 2
and HVACControllerMode.OFF in device.mode.controller_modes
):
self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
if (
self._device.mode is not None
and self._device.mode.operation_modes # empty list when not writable
device.mode is not None
and device.mode.operation_modes # empty list when not writable
):
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
self._attr_preset_modes = [
mode.name.lower() for mode in self._device.mode.operation_modes
mode.name.lower() for mode in device.mode.operation_modes
]
fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP]
self.fan_zero_mode = fan_zero_mode
self._fan_modes_percentages = [
int(100 * i / fan_max_step) for i in range(fan_max_step + 1)
]
self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE]
if self._device.fan_speed is not None and self._device.fan_speed.initialized:
if device.fan_speed is not None and device.fan_speed.initialized:
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
if fan_max_step == 3:
self._attr_fan_modes = [
self.fan_zero_mode,
fan_zero_mode,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
]
elif fan_max_step == 2:
self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH]
self._attr_fan_modes = [fan_zero_mode, FAN_LOW, FAN_HIGH]
elif fan_max_step == 1:
self._attr_fan_modes = [self.fan_zero_mode, FAN_ON]
elif self._device.fan_speed_mode == FanSpeedMode.STEP:
self._attr_fan_modes = [self.fan_zero_mode] + [
self._attr_fan_modes = [fan_zero_mode, FAN_ON]
elif device.fan_speed_mode == FanSpeedMode.STEP:
self._attr_fan_modes = [fan_zero_mode] + [
str(i) for i in range(1, fan_max_step + 1)
]
else:
self._attr_fan_modes = [self.fan_zero_mode] + [
self._attr_fan_modes = [fan_zero_mode] + [
f"{percentage}%" for percentage in self._fan_modes_percentages[1:]
]
if self._device.swing.initialized:
if device.swing.initialized:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._attr_swing_modes = [SWING_ON, SWING_OFF]
if self._device.horizontal_swing.initialized:
if device.horizontal_swing.initialized:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
self._attr_target_temperature_step = self._device.temperature_step
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
self.default_hvac_mode: HVACMode = config[
ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE
]
# non-OFF HVAC mode to be used when turning on the device without on_off address
self._last_hvac_mode: HVACMode = self.default_hvac_mode
self._attr_target_temperature_step = device.temperature_step
@property
def current_temperature(self) -> float | None:
@@ -475,3 +638,63 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
if hvac_mode is not HVACMode.OFF:
self._last_hvac_mode = hvac_mode
super().after_update_callback(device)
class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
"""Representation of a KNX climate device configured from YAML."""
_device: XknxClimate
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
)
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
fan_zero_mode: str = config[ClimateConf.FAN_ZERO_MODE]
self._init_from_device_config(
device=self._device,
default_hvac_mode=default_hvac_mode,
fan_max_step=fan_max_step,
fan_zero_mode=fan_zero_mode,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
class KnxUiClimate(_KnxClimate, KnxUiEntity):
"""Representation of a KNX climate device configured from the UI."""
_device: XknxClimate
def __init__(
self, knx_module: KNXModule, unique_id: str, config: ConfigType
) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = _create_climate_ui(
knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME]
)
default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE))
fan_max_step = knx_conf.get(ClimateConf.FAN_MAX_STEP)
fan_zero_mode = knx_conf.get(ClimateConf.FAN_ZERO_MODE)
self._init_from_device_config(
device=self._device,
default_hvac_mode=default_hvac_mode,
fan_max_step=fan_max_step,
fan_zero_mode=fan_zero_mode,
)

View File

@@ -160,6 +160,7 @@ SUPPORTED_PLATFORMS_YAML: Final = {
SUPPORTED_PLATFORMS_UI: Final = {
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SWITCH,
@@ -193,3 +194,23 @@ class CoverConf:
INVERT_UPDOWN: Final = "invert_updown"
INVERT_POSITION: Final = "invert_position"
INVERT_ANGLE: Final = "invert_angle"
class ClimateConf:
"""Common config keys for climate."""
MIN_TEMP: Final = "min_temp"
MAX_TEMP: Final = "max_temp"
TEMPERATURE_STEP: Final = "temperature_step"
SETPOINT_SHIFT_MAX: Final = "setpoint_shift_max"
SETPOINT_SHIFT_MIN: Final = "setpoint_shift_min"
ON_OFF_INVERT: Final = "on_off_invert"
OPERATION_MODES: Final = "operation_modes"
CONTROLLER_MODES: Final = "controller_modes"
DEFAULT_CONTROLLER_MODE: Final = "default_controller_mode"
FAN_MAX_STEP: Final = "fan_max_step"
FAN_SPEED_MODE: Final = "fan_speed_mode"
FAN_ZERO_MODE: Final = "fan_zero_mode"

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.10.0",
"xknxproject==3.8.2",
"knx-frontend==2025.10.17.202411"
"knx-frontend==2025.10.26.81530"
],
"single_config_entry": true
}

View File

@@ -56,6 +56,7 @@ from .const import (
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
KNX_ADDRESS,
ClimateConf,
ColorTempModes,
CoverConf,
FanZeroMode,
@@ -306,10 +307,7 @@ class ClimateSchema(KNXPlatformSchema):
CONF_SETPOINT_SHIFT_ADDRESS = "setpoint_shift_address"
CONF_SETPOINT_SHIFT_STATE_ADDRESS = "setpoint_shift_state_address"
CONF_SETPOINT_SHIFT_MODE = "setpoint_shift_mode"
CONF_SETPOINT_SHIFT_MAX = "setpoint_shift_max"
CONF_SETPOINT_SHIFT_MIN = "setpoint_shift_min"
CONF_TEMPERATURE_ADDRESS = "temperature_address"
CONF_TEMPERATURE_STEP = "temperature_step"
CONF_TARGET_TEMPERATURE_ADDRESS = "target_temperature_address"
CONF_TARGET_TEMPERATURE_STATE_ADDRESS = "target_temperature_state_address"
CONF_OPERATION_MODE_ADDRESS = "operation_mode_address"
@@ -327,19 +325,10 @@ class ClimateSchema(KNXPlatformSchema):
CONF_OPERATION_MODE_NIGHT_ADDRESS = "operation_mode_night_address"
CONF_OPERATION_MODE_COMFORT_ADDRESS = "operation_mode_comfort_address"
CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address"
CONF_OPERATION_MODES = "operation_modes"
CONF_CONTROLLER_MODES = "controller_modes"
CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode"
CONF_ON_OFF_ADDRESS = "on_off_address"
CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address"
CONF_ON_OFF_INVERT = "on_off_invert"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_FAN_SPEED_ADDRESS = "fan_speed_address"
CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address"
CONF_FAN_MAX_STEP = "fan_max_step"
CONF_FAN_SPEED_MODE = "fan_speed_mode"
CONF_FAN_ZERO_MODE = "fan_zero_mode"
CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address"
CONF_SWING_ADDRESS = "swing_address"
CONF_SWING_STATE_ADDRESS = "swing_state_address"
@@ -359,13 +348,13 @@ class ClimateSchema(KNXPlatformSchema):
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(
CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
): vol.All(int, vol.Range(min=0, max=32)),
vol.Optional(
CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
ClimateConf.SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
): vol.All(int, vol.Range(min=-32, max=0)),
vol.Optional(
CONF_TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
ClimateConf.TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
): vol.All(float, vol.Range(min=0, max=2)),
vol.Required(CONF_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): ga_list_validator,
@@ -408,29 +397,29 @@ class ClimateSchema(KNXPlatformSchema):
vol.Optional(CONF_ON_OFF_ADDRESS): ga_list_validator,
vol.Optional(CONF_ON_OFF_STATE_ADDRESS): ga_list_validator,
vol.Optional(
CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
ClimateConf.ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
): cv.boolean,
vol.Optional(CONF_OPERATION_MODES): vol.All(
vol.Optional(ClimateConf.OPERATION_MODES): vol.All(
cv.ensure_list,
[backwards_compatible_xknx_climate_enum_member(HVACOperationMode)],
),
vol.Optional(CONF_CONTROLLER_MODES): vol.All(
vol.Optional(ClimateConf.CONTROLLER_MODES): vol.All(
cv.ensure_list,
[backwards_compatible_xknx_climate_enum_member(HVACControllerMode)],
),
vol.Optional(
CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
): vol.Coerce(HVACMode),
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte,
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
vol.Optional(
CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
ClimateConf.FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
): vol.All(vol.Upper, cv.enum(FanSpeedMode)),
vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
vol.Optional(ClimateConf.FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
FanZeroMode
),
vol.Optional(CONF_SWING_ADDRESS): ga_list_validator,

View File

@@ -39,6 +39,10 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_DESCRIPTION_PLACEHOLDERS = {
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -48,6 +52,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_SEND,
service_send_to_knx_bus,
schema=SERVICE_KNX_SEND_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
hass.services.async_register(
@@ -63,6 +68,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EVENT_REGISTER,
service_event_register_modify,
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(
@@ -71,6 +77,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EXPOSURE_REGISTER,
service_exposure_register_modify,
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(

View File

@@ -14,6 +14,28 @@ CONF_DPT: Final = "dpt"
CONF_GA_SENSOR: Final = "ga_sensor"
CONF_GA_SWITCH: Final = "ga_switch"
# Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
CONF_TARGET_TEMPERATURE: Final = "target_temperature"
CONF_GA_TEMPERATURE_TARGET: Final = "ga_temperature_target"
CONF_GA_SETPOINT_SHIFT: Final = "ga_setpoint_shift"
CONF_GA_ACTIVE: Final = "ga_active"
CONF_GA_VALVE: Final = "ga_valve"
CONF_GA_OPERATION_MODE: Final = "ga_operation_mode"
CONF_IGNORE_AUTO_MODE: Final = "ignore_auto_mode"
CONF_GA_OP_MODE_COMFORT: Final = "ga_operation_mode_comfort"
CONF_GA_OP_MODE_ECO: Final = "ga_operation_mode_economy"
CONF_GA_OP_MODE_STANDBY: Final = "ga_operation_mode_standby"
CONF_GA_OP_MODE_PROTECTION: Final = "ga_operation_mode_protection"
CONF_GA_HEAT_COOL: Final = "ga_heat_cool"
CONF_GA_ON_OFF: Final = "ga_on_off"
CONF_GA_CONTROLLER_MODE: Final = "ga_controller_mode"
CONF_GA_CONTROLLER_STATUS: Final = "ga_controller_status"
CONF_GA_FAN_SPEED: Final = "ga_fan_speed"
CONF_GA_FAN_SWING: Final = "ga_fan_swing"
CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
# Cover
CONF_GA_UP_DOWN: Final = "ga_up_down"
CONF_GA_STOP: Final = "ga_stop"

View File

@@ -4,6 +4,7 @@ from enum import StrEnum, unique
import voluptuous as vol
from homeassistant.components.climate import HVACMode
from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_ENTITY_ID,
@@ -24,8 +25,10 @@ from ..const import (
CONF_SYNC_STATE,
DOMAIN,
SUPPORTED_PLATFORMS_UI,
ClimateConf,
ColorTempModes,
CoverConf,
FanZeroMode,
)
from .const import (
CONF_COLOR,
@@ -34,27 +37,47 @@ from .const import (
CONF_DATA,
CONF_DEVICE_INFO,
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_ANGLE,
CONF_GA_BLUE_BRIGHTNESS,
CONF_GA_BLUE_SWITCH,
CONF_GA_BRIGHTNESS,
CONF_GA_COLOR,
CONF_GA_COLOR_TEMP,
CONF_GA_CONTROLLER_MODE,
CONF_GA_CONTROLLER_STATUS,
CONF_GA_FAN_SPEED,
CONF_GA_FAN_SWING,
CONF_GA_FAN_SWING_HORIZONTAL,
CONF_GA_GREEN_BRIGHTNESS,
CONF_GA_GREEN_SWITCH,
CONF_GA_HEAT_COOL,
CONF_GA_HUE,
CONF_GA_HUMIDITY_CURRENT,
CONF_GA_ON_OFF,
CONF_GA_OP_MODE_COMFORT,
CONF_GA_OP_MODE_ECO,
CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE,
CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE,
CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_STEP,
CONF_GA_STOP,
CONF_GA_SWITCH,
CONF_GA_TEMPERATURE_CURRENT,
CONF_GA_TEMPERATURE_TARGET,
CONF_GA_UP_DOWN,
CONF_GA_VALVE,
CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH,
CONF_IGNORE_AUTO_MODE,
CONF_TARGET_TEMPERATURE,
)
from .knx_selector import (
AllSerializeFirst,
@@ -109,7 +132,9 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema(
min=0, max=600, step=0.1, unit_of_measurement="s"
)
),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
allow_false=True
),
},
)
@@ -311,8 +336,151 @@ SWITCH_KNX_SCHEMA = vol.Schema(
},
)
@unique
class ConfSetpointShiftMode(StrEnum):
"""Enum for setpoint shift mode."""
COUNT = "6.010"
FLOAT = "9.002"
@unique
class ConfClimateFanSpeedMode(StrEnum):
"""Enum for climate fan speed mode."""
PERCENTAGE = "5.001"
STEPS = "5.010"
CLIMATE_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_TEMPERATURE_CURRENT): GASelector(
write=False, state_required=True, valid_dpt="9.001"
),
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
write=False, valid_dpt="9.002"
),
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
GroupSelectOption(
translation_key="group_direct_temp",
schema={
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
write_required=True, valid_dpt="9.001"
),
vol.Required(
ClimateConf.MIN_TEMP, default=7
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-20, max=80, step=1, unit_of_measurement="°C"
)
),
vol.Required(
ClimateConf.MAX_TEMP, default=28
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=100, step=1, unit_of_measurement="°C"
)
),
vol.Required(
ClimateConf.TEMPERATURE_STEP, default=0.1
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1, max=2, step=0.1, unit_of_measurement="K"
),
),
},
),
GroupSelectOption(
translation_key="group_setpoint_shift",
schema={
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
write=False, state_required=True, valid_dpt="9.001"
),
vol.Required(CONF_GA_SETPOINT_SHIFT): GASelector(
write_required=True,
state_required=True,
dpt=ConfSetpointShiftMode,
),
vol.Required(
ClimateConf.SETPOINT_SHIFT_MIN, default=-6
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-32, max=0, step=1, unit_of_measurement="K"
)
),
vol.Required(
ClimateConf.SETPOINT_SHIFT_MAX, default=6
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=32, step=1, unit_of_measurement="K"
)
),
vol.Required(
ClimateConf.TEMPERATURE_STEP, default=0.1
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1, max=2, step=0.1, unit_of_measurement="K"
),
),
},
),
collapsible=False,
),
"section_activity": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_ACTIVE): GASelector(write=False, valid_dpt="1"),
vol.Optional(CONF_GA_VALVE): GASelector(write=False, valid_dpt="5.001"),
"section_operation_mode": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_OPERATION_MODE): GASelector(valid_dpt="20.102"),
vol.Optional(CONF_IGNORE_AUTO_MODE): selector.BooleanSelector(),
"section_operation_mode_individual": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_OP_MODE_COMFORT): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_ECO): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_STANDBY): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_PROTECTION): GASelector(
state=False, valid_dpt="1"
),
"section_heat_cool": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_HEAT_COOL): GASelector(valid_dpt="1.100"),
"section_on_off": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_ON_OFF): GASelector(valid_dpt="1"),
vol.Optional(ClimateConf.ON_OFF_INVERT): selector.BooleanSelector(),
"section_controller_mode": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_CONTROLLER_MODE): GASelector(valid_dpt="20.105"),
vol.Optional(CONF_GA_CONTROLLER_STATUS): GASelector(write=False),
vol.Required(
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(HVACMode),
translation_key="component.climate.selector.hvac_mode",
)
),
"section_fan": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_FAN_SPEED): GASelector(dpt=ConfClimateFanSpeedMode),
vol.Required(ClimateConf.FAN_MAX_STEP, default=3): AllSerializeFirst(
selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=100, step=1)
),
vol.Coerce(int),
),
vol.Required(
ClimateConf.FAN_ZERO_MODE, default=FanZeroMode.OFF
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(FanZeroMode),
translation_key="component.knx.config_panel.entities.create.climate.knx.fan_zero_mode",
)
),
vol.Optional(CONF_GA_FAN_SWING): GASelector(valid_dpt="1"),
vol.Optional(CONF_GA_FAN_SWING_HORIZONTAL): GASelector(valid_dpt="1"),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
},
)
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
Platform.COVER: COVER_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,

View File

@@ -269,7 +269,7 @@
},
"type": {
"name": "Value type",
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url})."
},
"response": {
"name": "Send as Response",
@@ -297,7 +297,7 @@
},
"type": {
"name": "Value type",
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url})."
},
"remove": {
"name": "Remove event registration",
@@ -315,7 +315,7 @@
},
"type": {
"name": "Value type",
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url})."
},
"entity_id": {
"name": "Entity",
@@ -412,6 +412,164 @@
}
}
},
"climate": {
"description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.",
"knx": {
"ga_temperature_current": {
"label": "Current temperature"
},
"ga_humidity_current": {
"label": "Current humidity"
},
"target_temperature": {
"title": "Target temperature",
"description": "Set the target temperature.",
"options": {
"group_direct_temp": {
"label": "Absolute setpoint",
"description": "Set the target temperature by an absolute value."
},
"group_setpoint_shift": {
"label": "Setpoint shift",
"description": "Shift the target temperature from a base setpoint."
}
},
"ga_temperature_target": {
"label": "Target temperature",
"description": "Current absolute target temperature."
},
"min_temp": {
"label": "Minimum temperature",
"description": "Minimum temperature that can be set."
},
"max_temp": {
"label": "Maximum temperature",
"description": "Maximum temperature that can be set."
},
"temperature_step": {
"label": "Temperature step",
"description": "Smallest step size to change the temperature. For setpoint shift configurations this sets the scale factor of the shift value."
},
"ga_setpoint_shift": {
"label": "Setpoint shift",
"description": "Target temperature deviation from a base setpoint."
},
"setpoint_shift_min": {
"label": "Minimum setpoint shift",
"description": "Lowest allowed deviation from the base setpoint."
},
"setpoint_shift_max": {
"label": "Maximum setpoint shift",
"description": "Highest allowed deviation from the base setpoint."
}
},
"section_activity": {
"title": "Activity",
"description": "Determine if the device is active or idle."
},
"ga_active": {
"label": "Active",
"description": "Binary value indicating if the device is active or idle. If configured, this takes precedence over valve position."
},
"ga_valve": {
"label": "Valve position",
"description": "Current control value / valve position in percent. `0` sets the climate entity to idle."
},
"section_operation_mode": {
"title": "Operation mode",
"description": "Set the preset mode of the device."
},
"ga_operation_mode": {
"label": "Operation mode",
"description": "Current operation mode."
},
"ignore_auto_mode": {
"label": "Ignore auto mode",
"description": "Enable when your controller doesn't support `auto` mode. It will be ignored by the integration then."
},
"section_operation_mode_individual": {
"title": "Individual operation modes",
"description": "Set the preset mode of the device using individual group addresses."
},
"ga_operation_mode_comfort": {
"label": "Comfort mode"
},
"ga_operation_mode_economy": {
"label": "Economy mode"
},
"ga_operation_mode_standby": {
"label": "Standby mode"
},
"ga_operation_mode_protection": {
"label": "Building protection mode"
},
"section_heat_cool": {
"title": "Heating/Cooling",
"description": "Set whether the device is in heating or cooling mode."
},
"ga_heat_cool": {
"label": "Heating/Cooling"
},
"section_on_off": {
"title": "On/Off",
"description": "Turn the device on or off."
},
"ga_on_off": {
"label": "On/Off"
},
"on_off_invert": {
"label": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::label%]",
"description": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::description%]"
},
"section_controller_mode": {
"title": "Controller mode",
"description": "Set the mode of the climate device."
},
"ga_controller_mode": {
"label": "Controller mode"
},
"ga_controller_status": {
"label": "Controller status",
"description": "HVAC controller mode and preset status. Eberle Status octet (KNX AN 097/07 rev 3) non-standardized DPT."
},
"default_controller_mode": {
"label": "Default mode",
"description": "Climate mode to be set on initialization."
},
"section_fan": {
"title": "Fan",
"description": "Configuration for fan control (AC units)."
},
"ga_fan_speed": {
"label": "Fan speed",
"description": "Set the current fan speed.",
"options": {
"5_001": "Percent",
"5_010": "Steps"
}
},
"fan_max_step": {
"label": "Fan steps",
"description": "The maximum amount of steps for the fan."
},
"fan_zero_mode": {
"label": "Zero fan speed mode",
"description": "Set the mode that represents fan speed `0`.",
"options": {
"off": "[%key:common::state::off%]",
"auto": "[%key:common::state::auto%]"
}
},
"ga_fan_swing": {
"label": "Fan swing",
"description": "Toggle (vertical) fan swing mode. Use this if only one direction is supported."
},
"ga_fan_swing_horizontal": {
"label": "Fan horizontal swing",
"description": "Toggle horizontal fan swing mode."
}
}
},
"cover": {
"description": "The KNX cover platform is used as an interface to shutter actuators.",
"knx": {

View File

@@ -20,13 +20,14 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType
from .adapter import MatterAdapter
from .addon import get_addon_manager
@@ -40,10 +41,13 @@ from .helpers import (
node_from_ha_device_id,
)
from .models import MatterDeviceInfo
from .services import async_setup_services
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@callback
@cache
@@ -64,6 +68,12 @@ def get_matter_device_info(
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Matter integration services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Matter from a config entry."""
if use_addon := entry.data.get(CONF_USE_ADDON):

View File

@@ -155,4 +155,18 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,),
value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id,
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
entity_description=MatterButtonEntityDescription(
key="WaterHeaterManagementCancelBoost",
translation_key="cancel_boost",
command=clusters.WaterHeaterManagement.Commands.CancelBoost,
),
entity_class=MatterCommandButton,
required_attributes=(
clusters.WaterHeaterManagement.Attributes.AcceptedCommandList,
),
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
allow_multi=True, # Also used in water_heater
),
]

View File

@@ -163,5 +163,10 @@
"default": "mdi:shield-lock"
}
}
},
"services": {
"water_heater_boost": {
"service": "mdi:water-boiler"
}
}
}

View File

@@ -0,0 +1,38 @@
"""Services for Matter devices."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_DURATION = "duration"
ATTR_EMERGENCY_BOOST = "emergency_boost"
ATTR_TEMPORARY_SETPOINT = "temporary_setpoint"
SERVICE_WATER_HEATER_BOOST = "water_heater_boost"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Matter services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_WATER_HEATER_BOOST,
entity_domain=WATER_HEATER_DOMAIN,
schema={
# duration >=1
vol.Required(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(ATTR_EMERGENCY_BOOST): cv.boolean,
vol.Optional(ATTR_TEMPORARY_SETPOINT): vol.All(
vol.Coerce(int), vol.Range(min=30, max=65)
),
},
func="async_set_boost",
)

View File

@@ -0,0 +1,26 @@
water_heater_boost:
target:
entity:
domain: water_heater
fields:
duration:
selector:
number:
min: 60
max: 14400
step: 60
mode: box
default: 3600
required: true
emergency_boost:
selector:
boolean:
default: false
temporary_setpoint:
selector:
number:
min: 30
max: 65
step: 1
mode: slider
default: 65

View File

@@ -106,6 +106,9 @@
}
},
"button": {
"cancel_boost": {
"name": "Cancel boost"
},
"pause": {
"name": "[%key:common::action::pause%]"
},
@@ -590,6 +593,24 @@
"description": "The Matter device to add to the other Matter network."
}
}
},
"water_heater_boost": {
"name": "Boost water heater",
"description": "Enables water heater boost for a specific duration.",
"fields": {
"duration": {
"name": "Duration",
"description": "Boost duration"
},
"emergency_boost": {
"name": "Emergency boost",
"description": "Whether to enable emergency boost mode."
},
"temporary_setpoint": {
"name": "Temporary setpoint",
"description": "Temporary setpoint temperature in Celsius during the boost period."
}
}
}
}
}

View File

@@ -6,7 +6,9 @@ from dataclasses import dataclass
from typing import Any, cast
from chip.clusters import Objects as clusters
from chip.clusters.Types import Nullable
from matter_server.client.models import device_types
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.water_heater import (
@@ -25,6 +27,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
@@ -40,6 +43,8 @@ WATER_HEATER_SYSTEM_MODE_MAP = {
STATE_OFF: 0,
}
DEFAULT_BOOST_DURATION = 3600 # 1 hour
async def async_setup_entry(
hass: HomeAssistant,
@@ -78,6 +83,30 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_platform_translation_key = "water_heater"
async def async_set_boost(
self,
duration: int,
emergency_boost: bool = False,
temporary_setpoint: int | None = None,
) -> None:
"""Set boost."""
boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
duration=duration,
emergencyBoost=emergency_boost,
temporarySetpoint=(
temporary_setpoint * TEMPERATURE_SCALING_FACTOR
if temporary_setpoint is not None
else Nullable
),
)
try:
await self.send_device_command(
clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info)
)
except MatterError as err:
raise HomeAssistantError(f"Error sending Boost command: {err}") from err
self._update_from_device()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
@@ -94,11 +123,11 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
self._attr_current_operation = operation_mode
# Boost 1h (3600s)
# Use the constant for boost duration
boost_info: type[
clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct
] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
duration=3600
duration=DEFAULT_BOOST_DURATION
)
system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode]
await self.write_attribute(

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.09.26"],
"requirements": ["yt-dlp[default]==2025.10.22"],
"single_config_entry": true
}

View File

@@ -248,7 +248,7 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -
Causes for this is config entry options changing.
"""
await hass.config_entries.async_reload(entry.entry_id)
hass.config_entries.async_schedule_reload(entry.entry_id)
@callback

View File

@@ -3815,9 +3815,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try_connection,
new_entry_data,
):
return self.async_update_reload_and_abort(
reauth_entry, data=new_entry_data
)
return self.async_update_and_abort(reauth_entry, data=new_entry_data)
errors["base"] = "invalid_auth"
@@ -3863,7 +3861,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if can_connect:
if is_reconfigure:
return self.async_update_reload_and_abort(
return self.async_update_and_abort(
reconfigure_entry,
data=validated_user_input,
)

View File

@@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PROFILE_ID, DOMAIN
@@ -23,11 +24,40 @@ AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
_LOGGER = logging.getLogger(__name__)
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
"""Check if credentials are valid."""
async def async_init_nextdns(
hass: HomeAssistant, api_key: str, profile_id: str | None = None
) -> NextDns:
"""Check if credentials and profile_id are valid."""
websession = async_get_clientsession(hass)
return await NextDns.create(websession, api_key)
nextdns = await NextDns.create(websession, api_key)
if profile_id:
if not any(profile.id == profile_id for profile in nextdns.profiles):
raise ProfileNotAvailable
return nextdns
async def async_validate_new_api_key(
hass: HomeAssistant, user_input: dict[str, Any], profile_id: str
) -> dict[str, str]:
"""Validate the new API key during reconfiguration or reauth."""
errors: dict[str, str] = {}
try:
await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id)
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
except ProfileNotAvailable:
errors["base"] = "profile_not_available"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -107,20 +137,19 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
if user_input is not None:
try:
await async_init_nextdns(self.hass, user_input[CONF_API_KEY])
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID]
)
if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available")
if not errors:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
entry,
data_updates=user_input,
)
return self.async_show_form(
@@ -128,3 +157,33 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=AUTH_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID]
)
if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available")
if not errors:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=AUTH_SCHEMA,
errors=errors,
)
class ProfileNotAvailable(HomeAssistantError):
"""Error to indicate that the profile is not available after reconfig/reauth."""

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["nextdns==4.1.0"]
}

View File

@@ -68,9 +68,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: todo
comment: Allow API key to be changed in the re-configure flow.
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration doesn't have any cases where raising an issue is needed.

View File

@@ -24,6 +24,14 @@
"data_description": {
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
}
},
"reconfigure": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
}
}
},
"error": {
@@ -33,7 +41,9 @@
},
"abort": {
"already_configured": "This NextDNS profile is already configured.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"system_health": {

View File

@@ -36,9 +36,9 @@ rules:
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/portainer",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.7"]
"requirements": ["pyportainer==1.0.9"]
}

View File

@@ -15,7 +15,7 @@ from roborock import (
RoborockInvalidUserAgreement,
RoborockNoUserAgreement,
)
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
from roborock.data import DeviceData, HomeDataDevice, HomeDataProduct, UserData
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockMqttClientA01
from roborock.web_api import RoborockApiClient

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from roborock.containers import RoborockStateCode
from roborock.data import RoborockStateCode
from roborock.roborock_typing import DeviceProp
from homeassistant.components.binary_sensor import (

View File

@@ -7,7 +7,7 @@ from copy import deepcopy
import logging
from typing import Any
from roborock.containers import UserData
from roborock.data import UserData
from roborock.exceptions import (
RoborockAccountDoesNotExist,
RoborockException,

View File

@@ -10,13 +10,13 @@ import logging
from propcache.api import cached_property
from roborock import HomeDataRoom
from roborock.code_mappings import RoborockCategory
from roborock.containers import (
from roborock.data import (
DeviceData,
HomeDataDevice,
HomeDataProduct,
HomeDataScene,
NetworkInfo,
RoborockCategory,
UserData,
)
from roborock.exceptions import RoborockException
@@ -38,6 +38,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util, slugify
@@ -274,6 +279,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
try:
await self.api.async_connect()
await self.api.ping()
async_delete_issue(
self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}"
)
except RoborockException:
_LOGGER.warning(
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
@@ -284,6 +292,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.api = self.cloud_api
self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL
self._is_cloud_api = True
async_create_issue(
self.hass,
DOMAIN,
f"cloud_api_used_{self.duid_slug}",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="cloud_api_used",
translation_placeholders={
"device_name": self.roborock_device_info.device.name
},
learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues",
)
# Right now this should never be called if the cloud api is the primary api,
# but in the future if it is, a new else should be added.

View File

@@ -4,7 +4,7 @@ from typing import Any
from roborock.api import RoborockClient
from roborock.command_cache import CacheableAttribute
from roborock.containers import Consumable, Status
from roborock.data import Consumable, Status
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand

View File

@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==2.50.2",
"python-roborock==3.3.3",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime
from typing import Any
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.data import HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.roborock_typing import DeviceProp
from vacuum_map_parser_base.map_data import MapData

View File

@@ -4,7 +4,7 @@ import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from roborock.code_mappings import RoborockDockDustCollectionModeCode
from roborock.data import RoborockDockDustCollectionModeCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import DeviceProp, RoborockCommand

View File

@@ -6,12 +6,15 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime
from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState
from roborock.containers import (
from roborock.data import (
DyadError,
RoborockDockErrorCode,
RoborockDockTypeCode,
RoborockDyadStateCode,
RoborockErrorCode,
RoborockStateCode,
ZeoError,
ZeoState,
)
from roborock.roborock_message import (
RoborockDataProtocol,

View File

@@ -39,6 +39,13 @@
"wrong_account": "Wrong account: Please authenticate with the right account."
}
},
"issues": {
"cloud_api_used": {
"title": "Cloud API used",
"description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance."
}
},
"options": {
"step": {
"drawables": {

View File

@@ -2,7 +2,7 @@
from typing import Any
from roborock.code_mappings import RoborockStateCode
from roborock.data import RoborockStateCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
import voluptuous as vol

View File

@@ -98,6 +98,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [
]
RPC_SLEEPING_PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.UPDATE,
]

View File

@@ -24,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
CONF_SLEEP_PERIOD,
DOMAIN,
LOGGER,
MODEL_FRANKEVER_WATER_VALVE,
@@ -34,6 +35,7 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo
from .entity import (
RpcEntityDescription,
ShellyRpcAttributeEntity,
ShellySleepingRpcAttributeEntity,
async_setup_entry_rpc,
get_entity_block_device_info,
get_entity_rpc_device_info,
@@ -190,9 +192,10 @@ async def async_setup_entry(
if TYPE_CHECKING:
assert coordinator is not None
await er.async_migrate_entries(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
if coordinator.device.initialized:
await er.async_migrate_entries(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
entities: list[ShellyButton] = []
@@ -208,22 +211,31 @@ async def async_setup_entry(
return
# add RPC buttons
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_BUTTONS, RpcVirtualButton
)
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc(
hass,
config_entry,
async_add_entities,
RPC_BUTTONS,
RpcSleepingSmokeMuteButton,
)
else:
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_BUTTONS, RpcVirtualButton
)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_button_component_ids = get_virtual_component_ids(
coordinator.device.config, BUTTON_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BUTTON_PLATFORM,
virtual_button_component_ids,
)
# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_button_component_ids = get_virtual_component_ids(
coordinator.device.config, BUTTON_PLATFORM
)
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BUTTON_PLATFORM,
virtual_button_component_ids,
)
class ShellyBaseButton(
@@ -354,6 +366,31 @@ class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
await self.coordinator.device.button_trigger(self._id, "single_push")
class RpcSleepingSmokeMuteButton(ShellySleepingRpcAttributeEntity, ButtonEntity):
"""Defines a Shelly RPC Smoke mute alarm button."""
entity_description: RpcButtonDescription
@rpc_call
async def async_press(self) -> None:
"""Triggers the Shelly button press service."""
if TYPE_CHECKING:
assert isinstance(self.coordinator, ShellyRpcCoordinator)
_id = int(self.key.split(":")[-1])
await self.coordinator.device.smoke_mute_alarm(_id)
@property
def available(self) -> bool:
"""Available."""
available = super().available
if self.coordinator.device.initialized:
return available and self.status["alarm"]
return False
RPC_BUTTONS = {
"button_generic": RpcButtonDescription(
key="button",
@@ -379,4 +416,10 @@ RPC_BUTTONS = {
entity_class=ShellyBluTrvButton,
models={MODEL_BLU_GATEWAY_G3},
),
"smoke_mute": RpcButtonDescription(
key="smoke",
sub_key="mute",
name="Mute alarm",
translation_key="mute",
),
}

View File

@@ -399,6 +399,11 @@ class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity):
"""Return the rgbw color value [int, int, int, int]."""
return (*self.status["rgb"], self.status["white"])
@property
def color_temp_kelvin(self) -> int:
"""Return the CT color value in Kelvin."""
return cast(int, self.status["ct"])
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
params: dict[str, Any] = {"id": self._id, "on": True}
@@ -421,6 +426,12 @@ class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity):
params["rgb"] = list(kwargs[ATTR_RGBW_COLOR][:-1])
params["white"] = kwargs[ATTR_RGBW_COLOR][-1]
if self.status.get("mode") is not None:
if ATTR_COLOR_TEMP_KELVIN in kwargs:
params["mode"] = "cct"
elif ATTR_RGB_COLOR in kwargs:
params["mode"] = "rgb"
await self.call_rpc(f"{self._component}.Set", params)
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -479,10 +490,24 @@ class RpcShellyCctLight(RpcShellyLightBase):
self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE
self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE
class RpcShellyRgbCctLight(RpcShellyLightBase):
"""Entity that controls a RGBCCT light on RPC based Shelly devices."""
_component = "RGBCCT"
_attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB}
_attr_supported_features = LightEntityFeature.TRANSITION
_attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE
_attr_max_color_temp_kelvin = KELVIN_MAX_VALUE
@property
def color_temp_kelvin(self) -> int:
"""Return the CT color value in Kelvin."""
return cast(int, self.status["ct"])
def color_mode(self) -> ColorMode:
"""Return the color mode."""
if self.status["mode"] == "cct":
return ColorMode.COLOR_TEMP
return ColorMode.RGB
class RpcShellyRgbLight(RpcShellyLightBase):
@@ -529,6 +554,11 @@ LIGHTS: Final = {
sub_key="output",
entity_class=RpcShellyRgbLight,
),
"rgbcct": RpcEntityDescription(
key="rgbcct",
sub_key="output",
entity_class=RpcShellyRgbCctLight,
),
"rgbw": RpcEntityDescription(
key="rgbw",
sub_key="output",

View File

@@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.14.0"],
"requirements": ["aioshelly==13.15.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -553,6 +553,14 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
"power_rgbcct": RpcSensorDescription(
key="rgbcct",
sub_key="apower",
name="Power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
"a_act_power": RpcSensorDescription(
key="em",
sub_key="a_act_power",
@@ -1023,6 +1031,17 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
"energy_rgbcct": RpcSensorDescription(
key="rgbcct",
sub_key="aenergy",
name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: status["total"],
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
"total_act": RpcSensorDescription(
key="emdata",
sub_key="total_act",

View File

@@ -165,6 +165,7 @@ RPC_SWITCHES = {
"boolean_zone0": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -175,6 +176,7 @@ RPC_SWITCHES = {
"boolean_zone1": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -185,6 +187,7 @@ RPC_SWITCHES = {
"boolean_zone2": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -195,6 +198,7 @@ RPC_SWITCHES = {
"boolean_zone3": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -205,6 +209,7 @@ RPC_SWITCHES = {
"boolean_zone4": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",
@@ -215,6 +220,7 @@ RPC_SWITCHES = {
"boolean_zone5": RpcSwitchDescription(
key="boolean",
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="boolean_set",
method_off="boolean_set",

View File

@@ -417,6 +417,11 @@ def get_rpc_sub_device_name(
"""Get name based on device and channel name."""
if key in device.config and key != "em:0":
# workaround for Pro 3EM, we don't want to get name for em:0
if (zone_id := get_irrigation_zone_id(device.config, key)) is not None:
# workaround for Irrigation controller, name stored in "service:0"
if zone_name := device.config["service:0"]["zones"][zone_id]["name"]:
return cast(str, zone_name)
if entity_name := device.config[key].get("name"):
return cast(str, entity_name)
@@ -787,6 +792,13 @@ async def get_rpc_scripts_event_types(
return script_events
def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None:
"""Return the zone id if the component is an irrigation zone."""
if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"):
return int(zone[4:])
return None
def get_rpc_device_info(
device: RpcDevice,
mac: str,
@@ -823,7 +835,10 @@ def get_rpc_device_info(
)
if (
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
(
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
and get_irrigation_zone_id(device.config, key) is None
)
or idx is None
or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2
):

View File

@@ -17,7 +17,11 @@ from homeassistant.components.valve import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE
from .const import (
MODEL_FRANKEVER_IRRIGATION_CONTROLLER,
MODEL_FRANKEVER_WATER_VALVE,
MODEL_NEO_WATER_VALVE,
)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -92,8 +96,8 @@ class RpcShellyWaterValve(RpcShellyBaseWaterValve):
await self.coordinator.device.number_set(self._id, position)
class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve):
"""Entity that controls a valve on RPC Shelly NEO Water Valve."""
class RpcShellySimpleWaterValve(RpcShellyBaseWaterValve):
"""Entity that controls a valve on RPC Shelly Open/Close Water Valve."""
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
_attr_reports_position = False
@@ -124,9 +128,51 @@ RPC_VALVES: dict[str, RpcValveDescription] = {
key="boolean",
sub_key="value",
role="state",
entity_class=RpcShellyNeoWaterValve,
entity_class=RpcShellySimpleWaterValve,
models={MODEL_NEO_WATER_VALVE},
),
"boolean_zone0": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone0",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
"boolean_zone1": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone1",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
"boolean_zone2": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone2",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
"boolean_zone3": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone3",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
"boolean_zone4": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone4",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
"boolean_zone5": RpcValveDescription(
key="boolean",
sub_key="value",
role="zone5",
entity_class=RpcShellySimpleWaterValve,
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
}

View File

@@ -2,7 +2,7 @@
"entity": {
"sensor": {
"thunder": {
"default": "mdi:lightning-bolt"
"default": "mdi:weather-lightning"
},
"total_cloud": {
"default": "mdi:cloud"

View File

@@ -0,0 +1,88 @@
"""The DALI Center integration."""
from __future__ import annotations
import logging
from PySrDaliGateway import DaliGateway
from PySrDaliGateway.exceptions import DaliGatewayError
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
from .types import DaliCenterConfigEntry, DaliCenterData
_PLATFORMS: list[Platform] = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Set up DALI Center from a config entry."""
gateway = DaliGateway(
entry.data[CONF_SERIAL_NUMBER],
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
name=entry.data[CONF_NAME],
)
gw_sn = gateway.gw_sn
try:
await gateway.connect()
except DaliGatewayError as exc:
raise ConfigEntryNotReady(
"You can try to delete the gateway and add it again"
) from exc
def on_online_status(dev_id: str, available: bool) -> None:
signal = f"{DOMAIN}_update_available_{dev_id}"
hass.add_job(async_dispatcher_send, hass, signal, available)
gateway.on_online_status = on_online_status
try:
devices = await gateway.discover_devices()
except DaliGatewayError as exc:
raise ConfigEntryNotReady(
"Unable to discover devices from the gateway"
) from exc
_LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn)
dev_reg = dr.async_get(hass)
dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, gw_sn)},
manufacturer=MANUFACTURER,
name=gateway.name,
model="SR-GW-EDA",
serial_number=gw_sn,
)
entry.runtime_data = DaliCenterData(
gateway=gateway,
devices=devices,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS):
await entry.runtime_data.gateway.disconnect()
return unload_ok

View File

@@ -0,0 +1,134 @@
"""Config flow for the DALI Center integration."""
from __future__ import annotations
import logging
from typing import Any
from PySrDaliGateway import DaliGateway
from PySrDaliGateway.discovery import DaliGatewayDiscovery
from PySrDaliGateway.exceptions import DaliGatewayError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)
from .const import CONF_SERIAL_NUMBER, DOMAIN
_LOGGER = logging.getLogger(__name__)
class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for DALI Center."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_gateways: dict[str, DaliGateway] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is not None:
return await self.async_step_select_gateway()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({}),
)
async def async_step_select_gateway(
self, discovery_info: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle gateway discovery."""
errors: dict[str, str] = {}
if discovery_info and "selected_gateway" in discovery_info:
selected_sn = discovery_info["selected_gateway"]
selected_gateway = self._discovered_gateways[selected_sn]
await self.async_set_unique_id(selected_gateway.gw_sn)
self._abort_if_unique_id_configured()
try:
await selected_gateway.connect()
except DaliGatewayError as err:
_LOGGER.debug(
"Failed to connect to gateway %s during config flow",
selected_gateway.gw_sn,
exc_info=err,
)
errors["base"] = "cannot_connect"
else:
await selected_gateway.disconnect()
return self.async_create_entry(
title=selected_gateway.name,
data={
CONF_SERIAL_NUMBER: selected_gateway.gw_sn,
CONF_HOST: selected_gateway.gw_ip,
CONF_PORT: selected_gateway.port,
CONF_NAME: selected_gateway.name,
CONF_USERNAME: selected_gateway.username,
CONF_PASSWORD: selected_gateway.passwd,
},
)
if not self._discovered_gateways:
_LOGGER.debug("Starting gateway discovery")
discovery = DaliGatewayDiscovery()
try:
discovered = await discovery.discover_gateways()
except DaliGatewayError as err:
_LOGGER.debug("Gateway discovery failed", exc_info=err)
errors["base"] = "discovery_failed"
else:
configured_gateways = {
entry.data[CONF_SERIAL_NUMBER]
for entry in self.hass.config_entries.async_entries(DOMAIN)
}
self._discovered_gateways = {
gw.gw_sn: gw
for gw in discovered
if gw.gw_sn not in configured_gateways
}
if not self._discovered_gateways:
return self.async_show_form(
step_id="select_gateway",
errors=errors if errors else {"base": "no_devices_found"},
data_schema=vol.Schema({}),
)
gateway_options = [
SelectOptionDict(
value=sn,
label=f"{gateway.name} [SN {sn}, IP {gateway.gw_ip}]",
)
for sn, gateway in self._discovered_gateways.items()
]
return self.async_show_form(
step_id="select_gateway",
data_schema=vol.Schema(
{
vol.Optional("selected_gateway"): SelectSelector(
SelectSelectorConfig(options=gateway_options, sort=True)
),
}
),
errors=errors,
)

View File

@@ -0,0 +1,5 @@
"""Constants for the DALI Center integration."""
DOMAIN = "sunricher_dali_center"
MANUFACTURER = "Sunricher"
CONF_SERIAL_NUMBER = "serial_number"

View File

@@ -0,0 +1,190 @@
"""Platform for light integration."""
from __future__ import annotations
import logging
from typing import Any
from PySrDaliGateway import Device
from PySrDaliGateway.helper import is_light_device
from PySrDaliGateway.types import LightStatus
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .types import DaliCenterConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: DaliCenterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up DALI Center light entities from config entry."""
runtime_data = entry.runtime_data
gateway = runtime_data.gateway
devices = runtime_data.devices
def _on_light_status(dev_id: str, status: LightStatus) -> None:
signal = f"{DOMAIN}_update_{dev_id}"
hass.add_job(async_dispatcher_send, hass, signal, status)
gateway.on_light_status = _on_light_status
async_add_entities(
DaliCenterLight(device)
for device in devices
if is_light_device(device.dev_type)
)
class DaliCenterLight(LightEntity):
"""Representation of a DALI Center Light."""
_attr_has_entity_name = True
_attr_name = None
_attr_is_on: bool | None = None
_attr_brightness: int | None = None
_white_level: int | None = None
_attr_color_mode: ColorMode | str | None = None
_attr_color_temp_kelvin: int | None = None
_attr_hs_color: tuple[float, float] | None = None
_attr_rgbw_color: tuple[int, int, int, int] | None = None
def __init__(self, light: Device) -> None:
"""Initialize the light entity."""
self._light = light
self._unavailable_logged = False
self._attr_unique_id = light.unique_id
self._attr_available = light.status == "online"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, light.dev_id)},
name=light.name,
manufacturer=MANUFACTURER,
model=light.model,
via_device=(DOMAIN, light.gw_sn),
)
self._attr_min_color_temp_kelvin = 1000
self._attr_max_color_temp_kelvin = 8000
self._determine_features()
def _determine_features(self) -> None:
supported_modes: set[ColorMode] = set()
color_mode = self._light.color_mode
color_mode_map: dict[str, ColorMode] = {
"color_temp": ColorMode.COLOR_TEMP,
"hs": ColorMode.HS,
"rgbw": ColorMode.RGBW,
}
self._attr_color_mode = color_mode_map.get(color_mode, ColorMode.BRIGHTNESS)
supported_modes.add(self._attr_color_mode)
self._attr_supported_color_modes = supported_modes
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
_LOGGER.debug(
"Turning on light %s with kwargs: %s", self._attr_unique_id, kwargs
)
brightness = kwargs.get(ATTR_BRIGHTNESS)
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
hs_color = kwargs.get(ATTR_HS_COLOR)
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
self._light.turn_on(
brightness=brightness,
color_temp_kelvin=color_temp_kelvin,
hs_color=hs_color,
rgbw_color=rgbw_color,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
self._light.turn_off()
async def async_added_to_hass(self) -> None:
"""Handle entity addition to Home Assistant."""
signal = f"{DOMAIN}_update_{self._attr_unique_id}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self._handle_device_update)
)
signal = f"{DOMAIN}_update_available_{self._attr_unique_id}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self._handle_availability)
)
# read_status() only queues a request on the gateway and relies on the
# current event loop via call_later, so it must run in the loop thread.
self._light.read_status()
@callback
def _handle_availability(self, available: bool) -> None:
self._attr_available = available
if not available and not self._unavailable_logged:
_LOGGER.info("Light %s became unavailable", self._attr_unique_id)
self._unavailable_logged = True
elif available and self._unavailable_logged:
_LOGGER.info("Light %s is back online", self._attr_unique_id)
self._unavailable_logged = False
self.schedule_update_ha_state()
@callback
def _handle_device_update(self, status: LightStatus) -> None:
if status.get("is_on") is not None:
self._attr_is_on = status["is_on"]
if status.get("brightness") is not None:
self._attr_brightness = status["brightness"]
if status.get("white_level") is not None:
self._white_level = status["white_level"]
if self._attr_rgbw_color is not None and self._white_level is not None:
self._attr_rgbw_color = (
self._attr_rgbw_color[0],
self._attr_rgbw_color[1],
self._attr_rgbw_color[2],
self._white_level,
)
if (
status.get("color_temp_kelvin") is not None
and self._attr_supported_color_modes
and ColorMode.COLOR_TEMP in self._attr_supported_color_modes
):
self._attr_color_temp_kelvin = status["color_temp_kelvin"]
if (
status.get("hs_color") is not None
and self._attr_supported_color_modes
and ColorMode.HS in self._attr_supported_color_modes
):
self._attr_hs_color = status["hs_color"]
if (
status.get("rgbw_color") is not None
and self._attr_supported_color_modes
and ColorMode.RGBW in self._attr_supported_color_modes
):
self._attr_rgbw_color = status["rgbw_color"]
self.async_write_ha_state()

View File

@@ -0,0 +1,10 @@
{
"domain": "sunricher_dali_center",
"name": "DALI Center",
"codeowners": ["@niracler"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["PySrDaliGateway==0.13.1"]
}

View File

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

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"title": "Set up DALI Center gateway",
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press."
},
"select_gateway": {
"title": "Select DALI gateway",
"description": "Select the gateway to configure.",
"data": {
"selected_gateway": "Gateway"
},
"data_description": {
"selected_gateway": "Each option shows the gateway name, serial number, and IP address."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_failed": "Failed to discover DALI gateways on the network",
"no_devices_found": "No DALI gateways found on the network",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Type definitions for the DALI Center integration."""
from dataclasses import dataclass
from PySrDaliGateway import DaliGateway, Device
from homeassistant.config_entries import ConfigEntry
@dataclass
class DaliCenterData:
"""Runtime data for the DALI Center integration."""
gateway: DaliGateway
devices: list[Device]
type DaliCenterConfigEntry = ConfigEntry[DaliCenterData]

View File

@@ -101,6 +101,7 @@ PLATFORMS_BY_TYPE = {
SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,

View File

@@ -24,7 +24,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
),
"motion_detected": BinarySensorEntityDescription(
key="pir_state",
name=None,
device_class=BinarySensorDeviceClass.MOTION,
),
"contact_open": BinarySensorEntityDescription(

View File

@@ -57,6 +57,7 @@ class SupportedModels(StrEnum):
RELAY_SWITCH_2PM = "relay_switch_2pm"
K11_PLUS_VACUUM = "k11+_vacuum"
GARAGE_DOOR_OPENER = "garage_door_opener"
CLIMATE_PANEL = "climate_panel"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -93,6 +94,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM,
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -106,6 +108,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.REMOTE: SupportedModels.REMOTE,
SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
SwitchbotModel.HUB3: SupportedModels.HUB3,
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
}
SUPPORTED_MODEL_TYPES = (

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["telegram"],
"quality_scale": "bronze",
"requirements": ["python-telegram-bot[socks]==21.5"]
"requirements": ["python-telegram-bot[socks]==22.1"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/thethingsnetwork",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["ttn_client==1.2.2"]
"requirements": ["ttn_client==1.2.3"]
}

View File

@@ -6,6 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
"quality_scale": "platinum",
"requirements": ["aiowebostv==0.7.5"],
"ssdp": [
{

View File

@@ -162,6 +162,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
self._entry = entry
self._client = entry.runtime_data
self._attr_assumed_state = True
self._unavailable_logged = False
self._device_name = entry.title
self._attr_unique_id = entry.unique_id
self._sources = entry.options.get(CONF_SOURCES)
@@ -348,19 +349,31 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
):
self._source_list["Live TV"] = app
def _set_availability(self, available: bool) -> None:
"""Set availability and log changes only once."""
self._attr_available = available
if not available and not self._unavailable_logged:
_LOGGER.info("LG webOS TV entity %s is unavailable", self.entity_id)
self._unavailable_logged = True
elif available and self._unavailable_logged:
_LOGGER.info("LG webOS TV entity %s is back online", self.entity_id)
self._unavailable_logged = False
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
async def async_update(self) -> None:
"""Connect."""
if self._client.is_connected():
return
with suppress(*WEBOSTV_EXCEPTIONS):
try:
await self._client.connect()
except WebOsTvPairError:
self._entry.async_start_reauth(self.hass)
else:
update_client_key(self.hass, self._entry)
try:
await self._client.connect()
except WEBOSTV_EXCEPTIONS:
self._set_availability(bool(self._turn_on))
except WebOsTvPairError:
self._entry.async_start_reauth(self.hass)
else:
self._set_availability(True)
update_client_key(self.hass, self._entry)
@property
def supported_features(self) -> MediaPlayerEntityFeature:

View File

@@ -26,9 +26,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -11,6 +11,7 @@ from xbox.webapi.api.provider.people.models import Person
from yarl import URL
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
from .entity import XboxBaseEntity
from .entity import XboxBaseEntity, check_deprecated_entity
class XboxBinarySensor(StrEnum):
@@ -37,6 +38,7 @@ class XboxBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_fn: Callable[[Person], bool | None]
entity_picture_fn: Callable[[Person], str | None] | None = None
deprecated: bool | None = None
def profile_pic(person: Person) -> str | None:
@@ -82,13 +84,8 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
),
XboxBinarySensorEntityDescription(
key=XboxBinarySensor.IN_PARTY,
translation_key=XboxBinarySensor.IN_PARTY,
is_on_fn=(
lambda x: bool(x.multiplayer_summary.in_party)
if x.multiplayer_summary
else None
),
entity_registry_enabled_default=False,
is_on_fn=lambda _: None,
deprecated=True,
),
XboxBinarySensorEntityDescription(
key=XboxBinarySensor.IN_GAME,
@@ -97,13 +94,8 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
),
XboxBinarySensorEntityDescription(
key=XboxBinarySensor.IN_MULTIPLAYER,
translation_key=XboxBinarySensor.IN_MULTIPLAYER,
is_on_fn=(
lambda x: bool(x.multiplayer_summary.in_multiplayer_session)
if x.multiplayer_summary
else None
),
entity_registry_enabled_default=False,
is_on_fn=lambda _: None,
deprecated=True,
),
XboxBinarySensorEntityDescription(
key=XboxBinarySensor.HAS_GAME_PASS,
@@ -121,7 +113,9 @@ async def async_setup_entry(
"""Set up Xbox Live friends."""
coordinator = entry.runtime_data
update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
update_friends = partial(
async_update_friends, hass, coordinator, {}, async_add_entities
)
entry.async_on_unload(coordinator.async_add_listener(update_friends))
@@ -152,6 +146,7 @@ class XboxBinarySensorEntity(XboxBaseEntity, BinarySensorEntity):
@callback
def async_update_friends(
hass: HomeAssistant,
coordinator: XboxUpdateCoordinator,
current: dict[str, list[XboxBinarySensorEntity]],
async_add_entities,
@@ -163,10 +158,11 @@ def async_update_friends(
# Process new favorites, add them to Home Assistant
new_entities: list[XboxBinarySensorEntity] = []
for xuid in new_ids - current_ids:
current[xuid] = [
XboxBinarySensorEntity(coordinator, xuid, description)
for description in SENSOR_DESCRIPTIONS
]
current[xuid] = []
for description in SENSOR_DESCRIPTIONS:
entity = XboxBinarySensorEntity(coordinator, xuid, description)
if check_deprecated_entity(hass, entity, BINARY_SENSOR_DOMAIN):
current[xuid].append(entity)
new_entities = new_entities + current[xuid]
if new_entities:
async_add_entities(new_entities)

View File

@@ -4,6 +4,10 @@ from __future__ import annotations
from xbox.webapi.api.provider.smartglass.models import ConsoleType, SmartglassConsole
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -83,3 +87,29 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
def data(self) -> ConsoleData:
"""Return coordinator data for this console."""
return self.coordinator.data.consoles[self._console.id]
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Get list of related automations and scripts."""
used_in = automations_with_entity(hass, entity_id)
used_in += scripts_with_entity(hass, entity_id)
return used_in
def check_deprecated_entity(
hass: HomeAssistant,
entity: XboxBaseEntity,
entity_domain: str,
) -> bool:
"""Check for deprecated entity and remove it."""
if not getattr(entity.entity_description, "deprecated", False):
return True
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(
entity_domain,
DOMAIN,
f"{entity.xuid}_{entity.entity_description.key}",
):
ent_reg.async_remove(entity_id)
return False

View File

@@ -7,12 +7,6 @@
"gamer_score": {
"default": "mdi:alpha-g-circle"
},
"account_tier": {
"default": "mdi:microsoft-xbox"
},
"gold_tenure": {
"default": "mdi:microsoft-xbox"
},
"last_online": {
"default": "mdi:account-clock"
},
@@ -27,15 +21,9 @@
"online": {
"default": "mdi:account"
},
"in_party": {
"default": "mdi:account-group"
},
"in_game": {
"default": "mdi:microsoft-xbox-controller"
},
"in_multiplayer": {
"default": "mdi:account-multiple"
},
"has_game_pass": {
"default": "mdi:microsoft-xbox"
}

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