Compare commits

...

149 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
f6277d0ec2 Merge branch 'dev' into tibber_data 2025-11-24 12:21:03 +01:00
TimL
ac69712a51 update firmware handling in SMLIGHT integration (#157145) 2025-11-24 12:13:28 +01:00
Jan Bouwhuis
f0e75ba0ed Add MQTT valve subentry support (#157124)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-11-24 12:04:38 +01:00
mettolen
e64598e7f5 Add light entity to Saunum integration (#157081)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-24 12:02:46 +01:00
vexofp
e6f9a8e7d6 Assign icons for more Octoprint sensors (#157150) 2025-11-24 11:51:58 +01:00
Josef Zweck
1e8b42f843 Bump pylamarzocco to 2.2.2 (#157165) 2025-11-24 11:50:11 +01:00
Franck Nijhof
430eee0b28 Address Home Assistant Labs review comments (#157075)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-24 11:34:38 +01:00
Paulus Schoutsen
b4799aa7ea Abort Z-Wave JS discovery from ESPHome if add-on umanaged (#157013)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 11:09:20 +01:00
Markus Jacobsen
ab45460069 Add Beoremote One support to Bang & Olufsen (#155082)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-24 08:17:04 +01:00
dependabot[bot]
c8fd6db3ff Bump actions/ai-inference from 2.0.1 to 2.0.2 (#157153) 2025-11-24 07:54:24 +01:00
Jan Bouwhuis
0a9f200ca4 Bump incomfort-client to v0.6.10 (#157136) 2025-11-24 06:31:50 +01:00
J. Nick Koston
8591335660 Bump dbus-fast to 3.1.2 (#157147) 2025-11-23 23:03:26 -05:00
TimL
c01089e994 Bump pysmlight to 0.2.11 (#157146) 2025-11-23 21:05:41 -06:00
Kamil Breguła
79a7daf89d Fix fixture for da_ks_oven_0107x (#157122)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-23 22:30:03 +01:00
David Rapan
d22867b852 Remove Shelly select name removal (#157070)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-23 21:24:55 +01:00
Amit Finkelstein
ddb74c5af4 Refresh HassOS coordinator when mount repair is received (#155969) 2025-11-23 20:51:18 +01:00
David Rapan
9aec7b12c2 Refactor Shelly entity to remove name assignments (#157018)
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-23 20:10:46 +01:00
J. Nick Koston
bf42e3769a Bump aioshelly to 13.21.0 (#157123) 2025-11-23 20:10:09 +01:00
Franck Nijhof
43f40c6f0e Extract issue template functions into an issues Jinja2 extension (#157116) 2025-11-23 19:14:46 +01:00
Manu
03ac634e6d Add aiofiles to requirements of matrix and slack integration (#157117) 2025-11-23 18:16:15 +01:00
Manu
a204e85d84 Fix typos in Duck DNS integration (#157118) 2025-11-23 18:05:08 +01:00
hahn-th
79c7ad7646 Handle variable number of channels for HmIPW-DRI16 and HmIPW-DRI32 in homematicip_cloud integration (#151201) 2025-11-23 17:53:05 +01:00
J. Diego Rodríguez Royo
704d4c896d Add air conditioner and microwave features to Home Connect (#151184)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-11-23 17:20:24 +01:00
Franck Nijhof
5b6a4b0fea Merge branch 'master' into dev 2025-11-23 16:08:46 +00:00
Jan Bouwhuis
ef5573c693 Allow to callback for MQTT subscription status (#152994) 2025-11-23 16:53:44 +01:00
J. Nick Koston
45aecd525a Fix Shelly BLE rediscovery after factory reset (#157113)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 16:50:41 +01:00
omrishiv
ce1146492e Enable Pylutron Caseta Smart Away (#156711) 2025-11-23 16:41:14 +01:00
J. Nick Koston
1ce890b105 Add repair issue for Shelly devices with open WiFi access point (#157086) 2025-11-23 07:40:38 -08:00
Janez Urevc
3e7bef77e5 Add total active power sensor to Tesla Wall Connector integration. (#151028)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-23 16:37:47 +01:00
puddly
1222828852 Show Z2M docs link in final step of hardware config flow (#155736) 2025-11-23 16:35:40 +01:00
skye-harris
1ef64582eb Bugfix Ollama Integration - Unable to reconfigure LLM Agents when an LLM Tooling API is removed (#156344) 2025-11-23 16:34:36 +01:00
w531t4
d363bd63eb Always expose Twitch channel_picture attr regardless of channel status (#150300) 2025-11-23 16:29:06 +01:00
Manu
5916af1115 Add config flow to Duck DNS integration (#147693)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 15:54:51 +01:00
tronikos
f8bf7ec1ff Add Google Weather sensors (#147141)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-23 15:43:08 +01:00
Jeremiah
41e42b9581 Fix Thermopro 'Device not available' on Restart (#155929) 2025-11-23 15:36:51 +01:00
Kevin Stillhammer
51f68f2776 Force httpx client to use IPv4 for waze_travel_time (#156526) 2025-11-23 15:05:02 +01:00
steinmn
773cb7424c Translatable error msg to frontend if new dashboard url already in use (#153501) 2025-11-23 14:43:44 +01:00
Artur Pragacz
eefab75ef0 Correct color mode when effect active in Wiz (#156742) 2025-11-23 14:13:22 +01:00
Markus Jacobsen
81b4122b73 Add proper Beosound Premiere support to Bang & Olufsen (#156954) 2025-11-23 13:58:26 +01:00
Franck Nijhof
fc8f8b39b4 2025.11.3 (#157006) 2025-11-21 18:02:26 +01:00
Franck Nijhof
ec0918027e Bump version to 2025.11.3 2025-11-21 16:27:45 +00:00
Joost Lekkerkerker
8a54f8d4e2 Throttle Decora wifi updates (#156994) 2025-11-21 16:26:49 +00:00
Bram Kragten
5c27126b6d Update frontend to 20251105.1 (#156992) 2025-11-21 16:26:47 +00:00
Robert Resch
e069aff0e2 Bump go2rtc to 1.9.12 and go2rtc-client to 0.3.0 (#156948) 2025-11-21 16:26:46 +00:00
Timothy
733526fae3 Rework CloudhookURL setup for mobile app (#156940) 2025-11-21 16:26:45 +00:00
Sebastian Schneider
1ef001f8e9 Bump aiounifi to 88 (#156867) 2025-11-21 16:26:43 +00:00
Josef Zweck
7732377fde Bump onedrive-personal-sdk to 0.0.17 (#156865) 2025-11-21 16:26:42 +00:00
puddly
b7786e589b Bump universal-silabs-flasher to 0.1.2 (#156849) 2025-11-21 16:26:41 +00:00
Joost Lekkerkerker
4f60970a91 Bump pySmartThings to 3.3.4 (#156830) 2025-11-21 16:26:40 +00:00
Thomas55555
1c1286dd57 Bump aioautomower to 2.7.1 (#156826) 2025-11-21 16:26:39 +00:00
Copilot
41c9f08f60 Fix hvv_departures to pass config_entry explicitly to DataUpdateCoordinator (#156794)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
2025-11-21 16:26:37 +00:00
Josef Zweck
fc4bfab0f7 Lamarzocco fix websocket reconnect issue (#156786)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-21 16:26:36 +00:00
epenet
769a12f74e Fix blocking call in cync (#156782) 2025-11-21 16:26:35 +00:00
Dan Raper
dabaa2bc5e Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-21 16:26:34 +00:00
Jan Bouwhuis
b674828a91 Fix missing temperature_delta device class translations (#156685) 2025-11-21 16:26:32 +00:00
Jan Bouwhuis
761da66658 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-21 16:26:31 +00:00
MarkGodwin
c8aba62301 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-21 16:26:30 +00:00
Robert Resch
07ab2e6805 Bump async-upnp-client to 0.46.0 (#156622) 2025-11-21 16:26:28 +00:00
Fredrik Mårtensson
f62e0c8c08 Fix is_matching in samsungtv config flow (#156594)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-21 16:26:27 +00:00
PaulCavill
6ca00f9dbb Bump pyiCloud to 2.2.0 (#156485) 2025-11-21 16:26:25 +00:00
Jamin
0fba80e30f Reset state on error during VOIP announcement (#156384) 2025-11-21 16:26:24 +00:00
puddly
7073c40385 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-21 16:26:23 +00:00
Charlie Rusbridger
8fb9d92daf Fix wrong BrowseError module in Kode (#155971) 2025-11-21 16:26:22 +00:00
cdnninja
2d81665f99 update methods to non deprecated methods in vesync (#155887) 2025-11-21 16:26:20 +00:00
Tom Monck JR
b398935539 Fix args passed to check_config script (#155885) 2025-11-21 16:26:19 +00:00
averybiteydinosaur
95f588aae1 Bump version of python_awair to 0.2.5 (#155798)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:18 +00:00
Hessel
ffe524d95a Cache token info in Wallbox (#154147)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-21 16:26:17 +00:00
Daniel Hjelseth Høyer
d7aa939f83 Merge branch 'tibber_data' of github.com:home-assistant/core into tibber_data 2025-11-19 06:53:01 +01:00
Daniel Hjelseth Høyer
77b349d00f test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-19 06:52:06 +01:00
Daniel Hjelseth Høyer
1c036128fa Merge branch 'dev' into tibber_data 2025-11-18 08:40:09 +01:00
Daniel Hjelseth Høyer
16d898cc8e test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 07:12:48 +01:00
Daniel Hjelseth Høyer
a7225c7cd4 Merge branch 'dev' into tibber_data 2025-11-18 06:51:29 +01:00
Daniel Hjelseth Høyer
433a429c5a test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 06:37:33 +01:00
Daniel Hjelseth Høyer
c4770ed423 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:57:03 +01:00
Daniel Hjelseth Høyer
df329fd273 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:36:43 +01:00
Daniel Hjelseth Høyer
6eb40574bc tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 19:39:19 +01:00
Daniel Hjelseth Høyer
4fd1ef5483 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 17:49:18 +01:00
Daniel Hjelseth Høyer
7ec5d5305d Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:38:01 +01:00
Daniel Hjelseth Høyer
7f31d2538e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:08:45 +01:00
Daniel Hjelseth Høyer
e1943307cf Merge branch 'dev' into tibber_data 2025-11-16 16:08:21 +01:00
Daniel Hjelseth Høyer
a06529d187 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 15:59:18 +01:00
Daniel Hjelseth Høyer
21554af6a1 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 12:14:03 +01:00
Franck Nijhof
ee05adfca1 2025.11.2 (#156620) 2025-11-14 23:09:51 +01:00
Franck Nijhof
168c915b5f Update snapshots 2025-11-14 21:43:53 +00:00
Franck Nijhof
6c80be52af Bump version to 2025.11.2 2025-11-14 21:15:12 +00:00
Simone Chemelli
ead92cdf82 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 21:14:11 +00:00
Thomas55555
c0f0cfef59 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 21:14:09 +00:00
epenet
cefc0ba96e Fix sfr_box entry reload (#156593) 2025-11-14 21:14:08 +00:00
TheJulianJES
ad091b1062 Bump ZHA to 0.0.79 (#156571) 2025-11-14 21:14:07 +00:00
TheJulianJES
876bc6d8c4 Bump ZHA to 0.0.78 (#155937) 2025-11-14 21:14:05 +00:00
Joost Lekkerkerker
9f206d4363 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 21:12:17 +00:00
starkillerOG
a2d11e6d98 Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 21:12:16 +00:00
Willem-Jan van Rootselaar
3b38af3984 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-14 21:12:14 +00:00
Joost Lekkerkerker
3875f91bb9 Bump pySmartThings to 3.3.3 (#156528) 2025-11-14 21:12:13 +00:00
Jan Čermák
c813776b0c Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-14 21:12:12 +00:00
Foscam-wangzhengyu
3afb421cba URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:10 +00:00
puddly
c16633568b Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-14 21:12:09 +00:00
Josef Zweck
87f8ff2bb4 Fix lamarzocco update status (#156442) 2025-11-14 21:12:08 +00:00
cdnninja
b423303f1e Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-14 21:12:06 +00:00
Brett Adams
f6ff222679 Fix update progress in Teslemetry (#156422) 2025-11-14 21:12:05 +00:00
Manu
0152fa0c03 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 21:12:03 +00:00
Daniel Hjelseth Høyer
37ebbe83bc Update pyMill to 0.14.1 (#156396) 2025-11-14 21:12:02 +00:00
antoniocifu
63e036d39e Fix support for Hyperion 2.1.1 (#156343)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:12:01 +00:00
Erik Montnemery
f0cbf34a78 Check collation of statistics_meta DB table (#156327) 2025-11-14 21:11:59 +00:00
Teemu R.
596bc89ee6 tplink: handle repeated, unknown thermostat modes gracefully (#156310) 2025-11-14 21:11:58 +00:00
Assaf Inbal
b8c877e1d2 Ituran: Don't cache properties (#156281) 2025-11-14 21:11:56 +00:00
Åke Strandberg
197d9781cb Improve logging of failing miele action commands (#156275) 2025-11-14 21:11:55 +00:00
Erik Montnemery
f3f323637e Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 21:11:54 +00:00
Joost Lekkerkerker
9748abc103 Bump pySmartThings to 3.3.2 (#156250) 2025-11-14 21:11:52 +00:00
dotvav
596f049971 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-14 21:11:51 +00:00
Foscam-wangzhengyu
dee80cb6f5 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 21:11:50 +00:00
Michael
b4ab73468b Fix Climate state reproduction when target temperature is None (#156220) 2025-11-14 21:11:48 +00:00
cdnninja
a300199a97 Bump pyvesync to 3.2.1 (#156195) 2025-11-14 21:11:47 +00:00
Simone Chemelli
09dd765583 Fix config flow reconfigure for Comelit (#156193) 2025-11-14 21:11:46 +00:00
starkillerOG
0c8b765415 Fix set_absolute_position angle (#156185) 2025-11-14 21:11:44 +00:00
Paul Annekov
0824ec502f Forbid to choose state in Ukraine Alarm integration (#156183) 2025-11-14 21:11:43 +00:00
Matthias Alphart
9e0e353a5f Update xknx to 3.10.1 (#156177) 2025-11-14 21:11:42 +00:00
Abílio Costa
e934b006e2 Fix MFA Notify setup flow schema (#156158) 2025-11-14 21:11:40 +00:00
Jan Rieger
05479bb8fd Bump aio-ownet to 0.0.5 (#156157) 2025-11-14 21:11:39 +00:00
TheJulianJES
d07247566d Log HomeAssistantErrors in ZHA config flow (#156075) 2025-11-14 21:11:38 +00:00
Erwin Douna
19e6097df6 Bump pyportainter 1.0.14 (#156072) 2025-11-14 21:11:36 +00:00
Erwin Douna
2cff3cf29c Bump pyportainer 1.0.13 (#155783) 2025-11-14 21:11:35 +00:00
Timothy
5cac9b8e5e Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-14 21:09:04 +00:00
Erik Montnemery
c2a516ea32 Fix progress step bugs (#155923) 2025-11-14 21:09:03 +00:00
Nojus
192b38d3e2 Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 21:09:01 +00:00
puddly
bb018e3546 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 21:09:00 +00:00
Diogo Gomes
4919d73cc5 Bump cronsim to 2.7 (#155648) 2025-11-14 21:08:58 +00:00
Daniel Hjelseth Høyer
b4aae93c45 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 19:18:22 +01:00
Daniel Hjelseth Høyer
1f9c244c5c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 06:01:05 +01:00
Daniel Hjelseth Høyer
9fa1b1b8df Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 22:11:18 +01:00
Daniel Hjelseth Høyer
f3ac3ecf05 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 21:07:27 +01:00
Daniel Hjelseth Høyer
9477b2206b Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 20:07:57 +01:00
Franck Nijhof
f3ddffb5ff 2025.11.1 (#156076) 2025-11-07 13:29:37 -08:00
Franck Nijhof
9bdfa77fa0 Merge branch 'master' into rc 2025-11-07 12:41:56 -08:00
Franck Nijhof
c65003009f Bump version to 2025.11.1 2025-11-07 20:36:12 +00:00
Michael Hansen
0f722109b7 Bump intents to 2025.11.7 (#156063) 2025-11-07 20:35:56 +00:00
Foscam-wangzhengyu
f7d86dec3c Fix the exception caused by the missing Foscam integration key (#156022) 2025-11-07 20:35:55 +00:00
Josef Zweck
6b49c8a70c Bump onedrive-personal-sdk to 0.0.16 (#156021) 2025-11-07 20:35:54 +00:00
epenet
ab9a8f3e53 Bump tuya-device-sharing-sdk to 0.2.5 (#156014) 2025-11-07 20:35:53 +00:00
johanzander
4e12628266 Fix Growatt integration authentication error for legacy config entries (#155993)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-07 20:35:51 +00:00
Simone Chemelli
e6d8d4de42 Bump aioamazondevices to 8.0.1 (#155989) 2025-11-07 20:35:50 +00:00
tronikos
6620b90eb4 Fix SolarEdge unload failing when there are no sensors (#155979) 2025-11-07 20:35:49 +00:00
tronikos
6fd3af8891 Handle empty fields in SolarEdge config flow (#155978) 2025-11-07 20:35:48 +00:00
Åke Strandberg
46979b8418 Fix for corrupt restored state in miele consumption sensors (#155966) 2025-11-07 20:35:47 +00:00
Marc Mueller
1718a11de2 Truncate password before sending it to bcrypt (#155950) 2025-11-07 20:35:45 +00:00
Matthias Alphart
2016b1d8c7 Fix KNX Climate humidity DPT (#155942) 2025-11-07 20:35:44 +00:00
puddly
4b72e45fc2 Remove @progress_step decorator from ZHA and Hardware integration (#155867)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-07 20:35:43 +00:00
Ståle Storø Hauknes
ead5ce905b Improve scan interval for Airthings Corentium Home 2 (#155694)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-07 20:35:42 +00:00
Franck Nijhof
f233f2da3f Bump version to 2025.11.0 2025-11-05 19:21:40 +00:00
168 changed files with 10277 additions and 2857 deletions

View File

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

View File

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

2
CODEOWNERS generated
View File

@@ -391,6 +391,8 @@ build.json @home-assistant/supervisor
/tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd

View File

@@ -21,10 +21,10 @@ from .const import (
ATTR_ITEM_NUMBER,
ATTR_SERIAL_NUMBER,
ATTR_TYPE_NUMBER,
COMPATIBLE_MODELS,
CONF_SERIAL_NUMBER,
DEFAULT_MODEL,
DOMAIN,
SELECTABLE_MODELS,
)
from .util import get_serial_number_from_jid
@@ -70,7 +70,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_MODEL, default=DEFAULT_MODEL): SelectSelector(
SelectSelectorConfig(options=COMPATIBLE_MODELS)
SelectSelectorConfig(options=SELECTABLE_MODELS)
),
}
)

View File

@@ -62,6 +62,7 @@ class BangOlufsenMediaType(StrEnum):
class BangOlufsenModel(StrEnum):
"""Enum for compatible model names."""
# Mozart devices
BEOCONNECT_CORE = "Beoconnect Core"
BEOLAB_8 = "BeoLab 8"
BEOLAB_28 = "BeoLab 28"
@@ -71,7 +72,26 @@ class BangOlufsenModel(StrEnum):
BEOSOUND_BALANCE = "Beosound Balance"
BEOSOUND_EMERGE = "Beosound Emerge"
BEOSOUND_LEVEL = "Beosound Level"
BEOSOUND_PREMIERE = "Beosound Premiere"
BEOSOUND_THEATRE = "Beosound Theatre"
# Remote devices
BEOREMOTE_ONE = "Beoremote One"
# Physical "buttons" on devices
class BangOlufsenButtons(StrEnum):
"""Enum for device buttons."""
BLUETOOTH = "Bluetooth"
MICROPHONE = "Microphone"
NEXT = "Next"
PLAY_PAUSE = "PlayPause"
PRESET_1 = "Preset1"
PRESET_2 = "Preset2"
PRESET_3 = "Preset3"
PRESET_4 = "Preset4"
PREVIOUS = "Previous"
VOLUME = "Volume"
# Dispatcher events
@@ -79,6 +99,7 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
BEO_REMOTE_BUTTON = "beo_remote_button"
BUTTON = "button"
PLAYBACK_ERROR = "playback_error"
PLAYBACK_METADATA = "playback_metadata"
@@ -96,6 +117,7 @@ class WebsocketNotification(StrEnum):
BEOLINK_AVAILABLE_LISTENERS = "beolinkAvailableListeners"
CONFIGURATION = "configuration"
NOTIFICATION = "notification"
REMOTE_CONTROL_DEVICES = "remoteControlDevices"
REMOTE_MENU_CHANGED = "remoteMenuChanged"
ALL = "all"
@@ -111,7 +133,11 @@ CONF_SERIAL_NUMBER: Final = "serial_number"
CONF_BEOLINK_JID: Final = "jid"
# Models to choose from in manual configuration.
COMPATIBLE_MODELS: list[str] = [x.value for x in BangOlufsenModel]
SELECTABLE_MODELS: list[str] = [
model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
]
MANUFACTURER: Final[str] = "Bang & Olufsen"
# Attribute names for zeroconf discovery.
ATTR_TYPE_NUMBER: Final[str] = "tn"
@@ -204,29 +230,16 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
),
]
)
# Map for storing compatibility of devices.
MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
MODEL_SUPPORT_MAP = {
MODEL_SUPPORT_DEVICE_BUTTONS: (
BangOlufsenModel.BEOLAB_8,
BangOlufsenModel.BEOLAB_28,
BangOlufsenModel.BEOSOUND_2,
BangOlufsenModel.BEOSOUND_A5,
BangOlufsenModel.BEOSOUND_A9,
BangOlufsenModel.BEOSOUND_BALANCE,
BangOlufsenModel.BEOSOUND_EMERGE,
BangOlufsenModel.BEOSOUND_LEVEL,
BangOlufsenModel.BEOSOUND_THEATRE,
)
}
# Device events
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
EVENT_TRANSLATION_MAP: dict[str, str] = {
# Beoremote One
"KeyPress": "key_press",
"KeyRelease": "key_release",
# Physical "buttons"
"shortPress (Release)": "short_press_release",
"longPress (Timeout)": "long_press_timeout",
"longPress (Release)": "long_press_release",
@@ -236,18 +249,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
DEVICE_BUTTONS: Final[list[str]] = [
"Bluetooth",
"Microphone",
"Next",
"PlayPause",
"Preset1",
"Preset2",
"Preset3",
"Preset4",
"Previous",
"Volume",
]
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
@@ -258,6 +260,70 @@ DEVICE_BUTTON_EVENTS: Final[list[str]] = [
"very_long_press_release",
]
BEO_REMOTE_SUBMENU_CONTROL: Final[str] = "Control"
BEO_REMOTE_SUBMENU_LIGHT: Final[str] = "Light"
# Common for both submenus
BEO_REMOTE_KEYS: Final[tuple[str, ...]] = (
"Blue",
"Digit0",
"Digit1",
"Digit2",
"Digit3",
"Digit4",
"Digit5",
"Digit6",
"Digit7",
"Digit8",
"Digit9",
"Down",
"Green",
"Left",
"Play",
"Red",
"Rewind",
"Right",
"Select",
"Stop",
"Up",
"Wind",
"Yellow",
"Func1",
"Func2",
"Func3",
"Func4",
"Func5",
"Func6",
"Func7",
"Func8",
"Func9",
"Func10",
"Func11",
"Func12",
"Func13",
"Func14",
"Func15",
"Func16",
"Func17",
)
# "keys" that are unique to the Control submenu
BEO_REMOTE_CONTROL_KEYS: Final[tuple[str, ...]] = (
"Func18",
"Func19",
"Func20",
"Func21",
"Func22",
"Func23",
"Func24",
"Func25",
"Func26",
"Func27",
)
BEO_REMOTE_KEY_EVENTS: Final[list[str]] = ["key_press", "key_release"]
# Beolink Converter NL/ML sources need to be transformed to upper case
BEOLINK_JOIN_SOURCES_TO_UPPER = (
"aux_a",

View File

@@ -6,11 +6,13 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
from .const import DEVICE_BUTTONS, DOMAIN
from .const import DOMAIN
from .util import get_device_buttons
async def async_get_config_entry_diagnostics(
@@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics(
data["media_player"] = state_dict
# Add button Event entity states (if enabled)
for device_button in DEVICE_BUTTONS:
for device_button in get_device_buttons(config_entry.data[CONF_MODEL]):
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
):

View File

@@ -2,22 +2,34 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from mozart_api.models import PairedRemote
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BangOlufsenConfigEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DEVICE_BUTTONS,
MODEL_SUPPORT_DEVICE_BUTTONS,
MODEL_SUPPORT_MAP,
DOMAIN,
MANUFACTURER,
BangOlufsenModel,
WebsocketNotification,
)
from .entity import BangOlufsenEntity
from .util import get_device_buttons, get_remotes
PARALLEL_UPDATES = 0
@@ -27,25 +39,87 @@ async def async_setup_entry(
config_entry: BangOlufsenConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
"""Set up Event entities from config entry."""
entities: list[BangOlufsenEvent] = []
if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
async_add_entities(
BangOlufsenButtonEvent(config_entry, button_type)
for button_type in DEVICE_BUTTONS
async_add_entities(
BangOlufsenButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
)
# Check for connected Beoremote One
remotes = await get_remotes(config_entry.runtime_data.client)
for remote in remotes:
# Add Light keys
entities.extend(
[
BangOlufsenRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
)
for key_type in BEO_REMOTE_KEYS
]
)
# Add Control keys
entities.extend(
[
BangOlufsenRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
)
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
]
)
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
"""Event class for Button events."""
# If the remote is no longer available, then delete the device.
# The remote may appear as being available to the device after it has been unpaired on the remote
# As it has to be removed from the device on the app.
device_registry = dr.async_get(hass)
devices = device_registry.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
for device in devices:
if (
device.model == BangOlufsenModel.BEOREMOTE_ONE
and device.serial_number not in {remote.serial_number for remote in remotes}
):
device_registry.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id
)
async_add_entities(new_entities=entities)
class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
"""Base Event class."""
_attr_device_class = EventDeviceClass.BUTTON
_attr_entity_registry_enabled_default = False
def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
"""Initialize Event."""
super().__init__(config_entry, config_entry.runtime_data.client)
@callback
def _async_handle_event(self, event: str) -> None:
"""Handle event."""
self._trigger_event(event)
self.async_write_ha_state()
class BangOlufsenButtonEvent(BangOlufsenEvent):
"""Event class for Button events."""
_attr_event_types = DEVICE_BUTTON_EVENTS
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
"""Initialize Button."""
super().__init__(config_entry, config_entry.runtime_data.client)
super().__init__(config_entry)
self._attr_unique_id = f"{self._unique_id}_{button_type}"
@@ -59,20 +133,65 @@ class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{CONNECTION_STATUS}",
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
self._async_handle_event,
)
)
@callback
def _async_handle_event(self, event: str) -> None:
"""Handle event."""
self._trigger_event(event)
self.async_write_ha_state()
class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
"""Event class for Beoremote One key events."""
_attr_event_types = BEO_REMOTE_KEY_EVENTS
def __init__(
self,
config_entry: BangOlufsenConfigEntry,
remote: PairedRemote,
key_type: str,
) -> None:
"""Initialize Beoremote One key."""
super().__init__(config_entry)
if TYPE_CHECKING:
assert remote.serial_number
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BangOlufsenModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BangOlufsenModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, self._unique_id),
)
# Make the native key name Home Assistant compatible
self._attr_translation_key = key_type.lower().replace("/", "_")
self._key_type = key_type
async def async_added_to_hass(self) -> None:
"""Listen to WebSocket Beoremote One key events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEO_REMOTE_BUTTON}_{self._key_type}",
self._async_handle_event,
)
)

View File

@@ -1,4 +1,278 @@
{
"entity": {
"event": {
"control_blue": {
"default": "mdi:remote"
},
"control_digit0": {
"default": "mdi:remote"
},
"control_digit1": {
"default": "mdi:remote"
},
"control_digit2": {
"default": "mdi:remote"
},
"control_digit3": {
"default": "mdi:remote"
},
"control_digit4": {
"default": "mdi:remote"
},
"control_digit5": {
"default": "mdi:remote"
},
"control_digit6": {
"default": "mdi:remote"
},
"control_digit7": {
"default": "mdi:remote"
},
"control_digit8": {
"default": "mdi:remote"
},
"control_digit9": {
"default": "mdi:remote"
},
"control_down": {
"default": "mdi:remote"
},
"control_func1": {
"default": "mdi:remote"
},
"control_func10": {
"default": "mdi:remote"
},
"control_func11": {
"default": "mdi:remote"
},
"control_func12": {
"default": "mdi:remote"
},
"control_func13": {
"default": "mdi:remote"
},
"control_func14": {
"default": "mdi:remote"
},
"control_func15": {
"default": "mdi:remote"
},
"control_func16": {
"default": "mdi:remote"
},
"control_func17": {
"default": "mdi:remote"
},
"control_func18": {
"default": "mdi:remote"
},
"control_func19": {
"default": "mdi:remote"
},
"control_func2": {
"default": "mdi:remote"
},
"control_func20": {
"default": "mdi:remote"
},
"control_func21": {
"default": "mdi:remote"
},
"control_func22": {
"default": "mdi:remote"
},
"control_func23": {
"default": "mdi:remote"
},
"control_func24": {
"default": "mdi:remote"
},
"control_func25": {
"default": "mdi:remote"
},
"control_func26": {
"default": "mdi:remote"
},
"control_func27": {
"default": "mdi:remote"
},
"control_func3": {
"default": "mdi:remote"
},
"control_func4": {
"default": "mdi:remote"
},
"control_func5": {
"default": "mdi:remote"
},
"control_func6": {
"default": "mdi:remote"
},
"control_func7": {
"default": "mdi:remote"
},
"control_func8": {
"default": "mdi:remote"
},
"control_func9": {
"default": "mdi:remote"
},
"control_green": {
"default": "mdi:remote"
},
"control_left": {
"default": "mdi:remote"
},
"control_play": {
"default": "mdi:remote"
},
"control_red": {
"default": "mdi:remote"
},
"control_rewind": {
"default": "mdi:remote"
},
"control_right": {
"default": "mdi:remote"
},
"control_select": {
"default": "mdi:remote"
},
"control_stop": {
"default": "mdi:remote"
},
"control_up": {
"default": "mdi:remote"
},
"control_wind": {
"default": "mdi:remote"
},
"control_yellow": {
"default": "mdi:remote"
},
"light_blue": {
"default": "mdi:remote"
},
"light_digit0": {
"default": "mdi:remote"
},
"light_digit1": {
"default": "mdi:remote"
},
"light_digit2": {
"default": "mdi:remote"
},
"light_digit3": {
"default": "mdi:remote"
},
"light_digit4": {
"default": "mdi:remote"
},
"light_digit5": {
"default": "mdi:remote"
},
"light_digit6": {
"default": "mdi:remote"
},
"light_digit7": {
"default": "mdi:remote"
},
"light_digit8": {
"default": "mdi:remote"
},
"light_digit9": {
"default": "mdi:remote"
},
"light_down": {
"default": "mdi:remote"
},
"light_func1": {
"default": "mdi:remote"
},
"light_func10": {
"default": "mdi:remote"
},
"light_func11": {
"default": "mdi:remote"
},
"light_func12": {
"default": "mdi:remote"
},
"light_func13": {
"default": "mdi:remote"
},
"light_func14": {
"default": "mdi:remote"
},
"light_func15": {
"default": "mdi:remote"
},
"light_func16": {
"default": "mdi:remote"
},
"light_func17": {
"default": "mdi:remote"
},
"light_func2": {
"default": "mdi:remote"
},
"light_func3": {
"default": "mdi:remote"
},
"light_func4": {
"default": "mdi:remote"
},
"light_func5": {
"default": "mdi:remote"
},
"light_func6": {
"default": "mdi:remote"
},
"light_func7": {
"default": "mdi:remote"
},
"light_func8": {
"default": "mdi:remote"
},
"light_func9": {
"default": "mdi:remote"
},
"light_green": {
"default": "mdi:remote"
},
"light_left": {
"default": "mdi:remote"
},
"light_play": {
"default": "mdi:remote"
},
"light_red": {
"default": "mdi:remote"
},
"light_rewind": {
"default": "mdi:remote"
},
"light_right": {
"default": "mdi:remote"
},
"light_select": {
"default": "mdi:remote"
},
"light_stop": {
"default": "mdi:remote"
},
"light_up": {
"default": "mdi:remote"
},
"light_wind": {
"default": "mdi:remote"
},
"light_yellow": {
"default": "mdi:remote"
}
}
},
"services": {
"beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" },
"beolink_expand": { "service": "mdi:location-enter" },

View File

@@ -80,6 +80,7 @@ from .const import (
CONNECTION_STATUS,
DOMAIN,
FALLBACK_SOURCES,
MANUFACTURER,
VALID_MEDIA_TYPES,
BangOlufsenMediaType,
BangOlufsenSource,
@@ -201,7 +202,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{self._host}/#/",
identifiers={(DOMAIN, self._unique_id)},
manufacturer="Bang & Olufsen",
manufacturer=MANUFACTURER,
model=self._model,
serial_number=self._unique_id,
)
@@ -249,7 +250,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{signal}",
f"{DOMAIN}_{self._unique_id}_{signal}",
signal_handler,
)
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,16 @@
from __future__ import annotations
from typing import cast
from mozart_api.models import PairedRemote
from mozart_api.mozart_client import MozartClient
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -21,3 +26,30 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@")[0]
async def get_remotes(client: MozartClient) -> list[PairedRemote]:
"""Get paired remotes."""
bluetooth_remote_list = await client.get_bluetooth_remotes()
return [
remote
for remote in cast(list[PairedRemote], bluetooth_remote_list.items)
if remote.serial_number is not None
]
def get_device_buttons(model: BangOlufsenModel) -> list[str]:
"""Get supported buttons for a given model."""
buttons = DEVICE_BUTTONS.copy()
# Beosound Premiere does not have a bluetooth button
if model == BangOlufsenModel.BEOSOUND_PREMIERE:
buttons.remove(BangOlufsenButtons.BLUETOOTH)
# Beoconnect Core does not have any buttons
elif model == BangOlufsenModel.BEOCONNECT_CORE:
buttons = []
return buttons

View File

@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING
from mozart_api.models import (
BeoRemoteButton,
ButtonEvent,
ListeningModeProps,
PlaybackContentMetadata,
@@ -28,11 +29,13 @@ from homeassistant.util.enum import try_parse_enum
from .const import (
BANG_OLUFSEN_WEBSOCKET_EVENT,
CONNECTION_STATUS,
DOMAIN,
EVENT_TRANSLATION_MAP,
BangOlufsenModel,
WebsocketNotification,
)
from .entity import BangOlufsenBase
from .util import get_device
from .util import get_device, get_remotes
_LOGGER = logging.getLogger(__name__)
@@ -57,6 +60,9 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
self._client.get_beo_remote_button_notifications(
self.on_beo_remote_button_notification
)
self._client.get_button_notifications(self.on_button_notification)
self._client.get_playback_error_notifications(
@@ -87,7 +93,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Update all entities of the connection status."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{CONNECTION_STATUS}",
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._client.websocket_connected,
)
@@ -105,10 +111,22 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send active_listening_mode dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
notification,
)
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
"""Send beo_remote_button dispatch."""
if TYPE_CHECKING:
assert notification.type
# Send to event entity
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEO_REMOTE_BUTTON}_{notification.key}",
EVENT_TRANSLATION_MAP[notification.type],
)
def on_button_notification(self, notification: ButtonEvent) -> None:
"""Send button dispatch."""
# State is expected to always be available.
@@ -118,11 +136,11 @@ class BangOlufsenWebsocket(BangOlufsenBase):
# Send to event entity
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
EVENT_TRANSLATION_MAP[notification.state],
)
def on_notification_notification(
async def on_notification_notification(
self, notification: WebsocketNotificationTag
) -> None:
"""Send notification dispatch."""
@@ -136,24 +154,51 @@ class BangOlufsenWebsocket(BangOlufsenBase):
):
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEOLINK}",
)
elif notification_type is WebsocketNotification.CONFIGURATION:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
)
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
)
# This notification is triggered by a remote pairing, unpairing and connecting to a device
# So the current remote devices have to be compared to available remotes to determine action
elif notification_type is WebsocketNotification.REMOTE_CONTROL_DEVICES:
device_registry = dr.async_get(self.hass)
# Get remote devices connected to the device from Home Assistant
device_serial_numbers = [
device.serial_number
for device in device_registry.devices.get_devices_for_config_entry_id(
self.entry.entry_id
)
if device.serial_number is not None
and device.model == BangOlufsenModel.BEOREMOTE_ONE
]
# Get paired remotes from device
remote_serial_numbers = [
remote.serial_number
for remote in await get_remotes(self._client)
if remote.serial_number is not None
]
# Check if number of remote devices correspond to number of paired remotes
if len(remote_serial_numbers) != len(device_serial_numbers):
_LOGGER.info(
"A Beoremote One has been paired or unpaired to %s. Reloading config entry to add device and entities",
self.entry.title,
)
self.hass.config_entries.async_schedule_reload(self.entry.entry_id)
def on_playback_error_notification(self, notification: PlaybackError) -> None:
"""Send playback_error dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}",
notification,
)
@@ -163,7 +208,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send playback_metadata dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}",
notification,
)
@@ -171,7 +216,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send playback_progress dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}",
notification,
)
@@ -179,7 +224,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send playback_state dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}",
notification,
)
@@ -187,7 +232,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send playback_source dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
notification,
)
@@ -195,7 +240,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send source_change dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}",
notification,
)
@@ -203,7 +248,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
"""Send volume dispatch."""
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.VOLUME}",
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.VOLUME}",
notification,
)

View File

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

View File

@@ -10,6 +10,7 @@ from typing import Any, cast
from aiohttp import ClientSession
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import (
CALLBACK_TYPE,
@@ -18,13 +19,17 @@ from homeassistant.core import (
ServiceCall,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from .const import ATTR_CONFIG_ENTRY
_LOGGER = logging.getLogger(__name__)
ATTR_TXT = "txt"
@@ -32,7 +37,13 @@ ATTR_TXT = "txt"
DOMAIN = "duckdns"
INTERVAL = timedelta(minutes=5)
BACKOFF_INTERVALS = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
SERVICE_SET_TXT = "set_txt"
UPDATE_URL = "https://www.duckdns.org/update"
@@ -49,39 +60,112 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)})
SERVICE_TXT_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
}
)
type DuckDnsConfigEntry = ConfigEntry
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the DuckDNS component."""
domain: str = config[DOMAIN][CONF_DOMAIN]
token: str = config[DOMAIN][CONF_ACCESS_TOKEN]
hass.services.async_register(
DOMAIN,
SERVICE_SET_TXT,
update_domain_service,
schema=SERVICE_TXT_SCHEMA,
)
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Set up Duck DNS from a config entry."""
session = async_get_clientsession(hass)
async def update_domain_interval(_now: datetime) -> bool:
"""Update the DuckDNS entry."""
return await _update_duckdns(session, domain, token)
return await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
)
intervals = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
hass.services.async_register(
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
entry.async_on_unload(
async_track_time_interval_backoff(
hass, update_domain_interval, BACKOFF_INTERVALS
)
)
return True
def get_config_entry(
hass: HomeAssistant, entry_id: str | None = None
) -> DuckDnsConfigEntry:
"""Return config entry or raise if not found or not loaded."""
if entry_id is None:
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if len(config_entries) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_selected",
)
return config_entries[0]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
return entry
async def update_domain_service(call: ServiceCall) -> None:
"""Update the DuckDNS entry."""
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
session = async_get_clientsession(call.hass)
await _update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
"""Unload a config entry."""
return True
_SENTINEL = object()

View File

@@ -0,0 +1,81 @@
"""Config flow for the Duck DNS integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import _update_duckdns
from .const import DOMAIN
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DOMAIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, suffix=".duckdns.org")
),
vol.Required(CONF_ACCESS_TOKEN): str,
}
)
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Duck DNS."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
session = async_get_clientsession(self.hass)
try:
if not await _update_duckdns(
session,
user_input[CONF_DOMAIN],
user_input[CONF_ACCESS_TOKEN],
):
errors["base"] = "update_failed"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=f"{user_input[CONF_DOMAIN]}.duckdns.org", data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={"url": "https://www.duckdns.org/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result

View File

@@ -0,0 +1,7 @@
"""Constants for the Duck DNS integration."""
from typing import Final
DOMAIN = "duckdns"
ATTR_CONFIG_ENTRY: Final = "config_entry_id"

View File

@@ -0,0 +1,40 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.6.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Duck DNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)

View File

@@ -1,8 +1,8 @@
{
"domain": "duckdns",
"name": "Duck DNS",
"codeowners": [],
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/duckdns",
"iot_class": "cloud_polling",
"quality_scale": "legacy"
"iot_class": "cloud_polling"
}

View File

@@ -1,7 +1,10 @@
set_txt:
fields:
config_entry_id:
selector:
config_entry:
integration: duckdns
txt:
required: true
example: "This domain name is reserved for use in documentation"
selector:
text:

View File

@@ -1,8 +1,48 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating Duck DNS failed"
},
"step": {
"user": {
"data": {
"access_token": "Token",
"domain": "Subdomain"
},
"data_description": {
"access_token": "Your Duck DNS account token",
"domain": "The Duck DNS subdomain to update"
},
"description": "Enter your Duck DNS subdomain and token below to configure dynamic DNS updates. You can find your token on the [Duck DNS]({url}) homepage after logging into your account."
}
}
},
"exceptions": {
"entry_not_found": {
"message": "Duck DNS integration entry not found"
},
"entry_not_selected": {
"message": "Duck DNS integration entry not selected"
}
},
"issues": {
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"
}
},
"services": {
"set_txt": {
"description": "Sets the TXT record of your DuckDNS subdomain.",
"description": "Sets the TXT record of your Duck DNS subdomain.",
"fields": {
"config_entry_id": {
"description": "The Duck DNS integration ID.",
"name": "Integration ID"
},
"txt": {
"description": "Payload for the TXT record.",
"name": "TXT"

View File

@@ -20,7 +20,7 @@ from .coordinator import (
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.WEATHER]
_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(

View File

@@ -16,10 +16,15 @@ class GoogleWeatherBaseEntity(Entity):
_attr_has_entity_name = True
def __init__(
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
self,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
unique_id_suffix: str | None = None,
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
if unique_id_suffix is not None:
self._attr_unique_id += f"_{unique_id_suffix.lower()}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,

View File

@@ -0,0 +1,27 @@
{
"entity": {
"sensor": {
"cloud_coverage": {
"default": "mdi:weather-cloudy"
},
"precipitation_probability": {
"default": "mdi:weather-rainy"
},
"precipitation_qpf": {
"default": "mdi:cup-water"
},
"thunderstorm_probability": {
"default": "mdi:weather-lightning"
},
"uv_index": {
"default": "mdi:weather-sunny-alert"
},
"visibility": {
"default": "mdi:eye"
},
"weather_condition": {
"default": "mdi:card-text-outline"
}
}
}
}

View File

@@ -0,0 +1,233 @@
"""Support for Google Weather sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from google_weather_api import CurrentConditionsResponse
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
UV_INDEX,
UnitOfLength,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GoogleWeatherSensorDescription(SensorEntityDescription):
"""Class describing Google Weather sensor entities."""
value_fn: Callable[[CurrentConditionsResponse], str | int | float | None]
SENSOR_TYPES: tuple[GoogleWeatherSensorDescription, ...] = (
GoogleWeatherSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.temperature.degrees,
),
GoogleWeatherSensorDescription(
key="feelsLikeTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.feels_like_temperature.degrees,
translation_key="apparent_temperature",
),
GoogleWeatherSensorDescription(
key="dewPoint",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.dew_point.degrees,
translation_key="dew_point",
),
GoogleWeatherSensorDescription(
key="heatIndex",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.heat_index.degrees,
translation_key="heat_index",
),
GoogleWeatherSensorDescription(
key="windChill",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: data.wind_chill.degrees,
translation_key="wind_chill",
),
GoogleWeatherSensorDescription(
key="relativeHumidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.relative_humidity,
),
GoogleWeatherSensorDescription(
key="uvIndex",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: data.uv_index,
translation_key="uv_index",
),
GoogleWeatherSensorDescription(
key="precipitation_probability",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.precipitation.probability.percent,
translation_key="precipitation_probability",
),
GoogleWeatherSensorDescription(
key="precipitation_qpf",
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
value_fn=lambda data: data.precipitation.qpf.quantity,
),
GoogleWeatherSensorDescription(
key="thunderstormProbability",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.thunderstorm_probability,
translation_key="thunderstorm_probability",
),
GoogleWeatherSensorDescription(
key="airPressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
native_unit_of_measurement=UnitOfPressure.HPA,
value_fn=lambda data: data.air_pressure.mean_sea_level_millibars,
),
GoogleWeatherSensorDescription(
key="wind_direction",
device_class=SensorDeviceClass.WIND_DIRECTION,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
native_unit_of_measurement=DEGREE,
value_fn=lambda data: data.wind.direction.degrees,
),
GoogleWeatherSensorDescription(
key="wind_speed",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: data.wind.speed.value,
),
GoogleWeatherSensorDescription(
key="wind_gust",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: data.wind.gust.value,
translation_key="wind_gust_speed",
),
GoogleWeatherSensorDescription(
key="visibility",
device_class=SensorDeviceClass.DISTANCE,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
value_fn=lambda data: data.visibility.distance,
translation_key="visibility",
),
GoogleWeatherSensorDescription(
key="cloudCover",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.cloud_cover,
translation_key="cloud_coverage",
),
GoogleWeatherSensorDescription(
key="weatherCondition",
entity_registry_enabled_default=False,
value_fn=lambda data: data.weather_condition.description.text,
translation_key="weather_condition",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Google Weather entities from a config_entry."""
for subentry in entry.subentries.values():
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
coordinator = subentry_runtime_data.coordinator_observation
async_add_entities(
(
GoogleWeatherSensor(coordinator, subentry, description)
for description in SENSOR_TYPES
if description.value_fn(coordinator.data) is not None
),
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherSensor(
CoordinatorEntity[GoogleWeatherCurrentConditionsCoordinator],
GoogleWeatherBaseEntity,
SensorEntity,
):
"""Define a Google Weather entity."""
entity_description: GoogleWeatherSensorDescription
def __init__(
self,
coordinator: GoogleWeatherCurrentConditionsCoordinator,
subentry: ConfigSubentry,
description: GoogleWeatherSensorDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
GoogleWeatherBaseEntity.__init__(
self, coordinator.config_entry, subentry, description.key
)
self.entity_description = description
@property
def native_value(self) -> str | int | float | None:
"""Return the state."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -61,5 +61,42 @@
}
}
}
},
"entity": {
"sensor": {
"apparent_temperature": {
"name": "Apparent temperature"
},
"cloud_coverage": {
"name": "Cloud coverage"
},
"dew_point": {
"name": "Dew point"
},
"heat_index": {
"name": "Heat index temperature"
},
"precipitation_probability": {
"name": "Precipitation probability"
},
"thunderstorm_probability": {
"name": "Thunderstorm probability"
},
"uv_index": {
"name": "UV index"
},
"visibility": {
"name": "Visibility"
},
"weather_condition": {
"name": "Weather condition"
},
"wind_chill": {
"name": "Wind chill temperature"
},
"wind_gust_speed": {
"name": "Wind gust speed"
}
}
}
}

View File

@@ -128,6 +128,8 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"

View File

@@ -27,6 +27,7 @@ from homeassistant.helpers.issue_registry import (
)
from .const import (
ADDONS_COORDINATOR,
ATTR_DATA,
ATTR_HEALTHY,
ATTR_STARTUP,
@@ -49,6 +50,7 @@ from .const import (
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_MOUNT_MOUNT_FAILED,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_URL,
PLACEHOLDER_KEY_FREE_SPACE,
@@ -57,7 +59,7 @@ from .const import (
STARTUP_COMPLETE,
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import get_addons_info, get_host_info
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
from .handler import HassIO, get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
@@ -77,7 +79,7 @@ UNSUPPORTED_SKIP_REPAIR = {"privileged"}
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_BOOT_FAIL,
"issue_mount_mount_failed",
ISSUE_MOUNT_MOUNT_FAILED,
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -284,6 +286,9 @@ class SupervisorIssues:
else:
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
self._async_coordinator_refresh()
async_create_issue(
self._hass,
DOMAIN,
@@ -336,6 +341,9 @@ class SupervisorIssues:
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_delete_issue(self._hass, DOMAIN, issue.uuid.hex)
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
self._async_coordinator_refresh()
del self._issues[issue.uuid]
def get_issue(self, issue_id: str) -> Issue | None:
@@ -406,3 +414,11 @@ class SupervisorIssues:
elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
self.remove_issue(Issue.from_dict(event[ATTR_DATA]))
def _async_coordinator_refresh(self) -> None:
"""Refresh coordinator to update latest data in entities."""
coordinator: HassioDataUpdateCoordinator | None
if coordinator := self._hass.data.get(ADDONS_COORDINATOR):
coordinator.config_entry.async_create_task(
self._hass, coordinator.async_refresh()
)

View File

@@ -13,11 +13,13 @@ DOMAIN = "home_connect"
API_DEFAULT_RETRY_AFTER = 60
APPLIANCES_WITH_PROGRAMS = (
"AirConditioner",
"CleaningRobot",
"CoffeeMaker",
"Dishwasher",
"Dryer",
"Hood",
"Microwave",
"Oven",
"WarmingDrawer",
"Washer",
@@ -83,6 +85,14 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
FAN_SPEED_MODE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
)
}
AVAILABLE_MAPS_ENUM = {
bsh_key_to_translation_key(option): option
for option in (
@@ -315,6 +325,10 @@ PROGRAM_ENUM_OPTIONS = {
options,
)
for option_key, options in (
(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_SPEED_MODE_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
AVAILABLE_MAPS_ENUM,

View File

@@ -82,6 +82,12 @@ set_program_and_options:
- dishcare_dishwasher_program_maximum_cleaning
- dishcare_dishwasher_program_mixed_load
- dishcare_dishwasher_program_learning_dishwasher
- heating_ventilation_air_conditioning_air_conditioner_program_active_clean
- heating_ventilation_air_conditioning_air_conditioner_program_auto
- heating_ventilation_air_conditioning_air_conditioner_program_cool
- heating_ventilation_air_conditioning_air_conditioner_program_dry
- heating_ventilation_air_conditioning_air_conditioner_program_fan
- heating_ventilation_air_conditioning_air_conditioner_program_heat
- laundry_care_dryer_program_cotton
- laundry_care_dryer_program_synthetic
- laundry_care_dryer_program_mix
@@ -136,6 +142,7 @@ set_program_and_options:
- cooking_oven_program_microwave_90_watt
- cooking_oven_program_microwave_180_watt
- cooking_oven_program_microwave_360_watt
- cooking_oven_program_microwave_450_watt
- cooking_oven_program_microwave_600_watt
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
@@ -177,6 +184,28 @@ set_program_and_options:
- laundry_care_washer_dryer_program_easy_care
- laundry_care_washer_dryer_program_wash_and_dry_60
- laundry_care_washer_dryer_program_wash_and_dry_90
air_conditioner_options:
collapsed: true
fields:
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage:
example: 50
required: false
selector:
number:
min: 1
max: 100
step: 1
mode: box
unit_of_measurement: "%"
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
required: false
selector:
select:
mode: dropdown
translation_key: fan_speed_mode
options:
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual
cleaning_robot_options:
collapsed: true
fields:

View File

@@ -252,6 +252,7 @@
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
@@ -281,6 +282,12 @@
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
@@ -443,6 +450,13 @@
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]"
}
},
"fan_speed_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode::name%]",
"state": {
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic%]",
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual%]"
}
},
"flow_rate": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
"state": {
@@ -575,6 +589,7 @@
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
@@ -604,6 +619,12 @@
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
@@ -1418,6 +1439,12 @@
"laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry"
}
},
"fan_speed_mode": {
"options": {
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "Auto",
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "Manual"
}
},
"flow_rate": {
"options": {
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
@@ -1526,6 +1553,7 @@
"cooking_oven_program_microwave_1000_watt": "1000 Watt",
"cooking_oven_program_microwave_180_watt": "180 Watt",
"cooking_oven_program_microwave_360_watt": "360 Watt",
"cooking_oven_program_microwave_450_watt": "450 Watt",
"cooking_oven_program_microwave_600_watt": "600 Watt",
"cooking_oven_program_microwave_900_watt": "900 Watt",
"cooking_oven_program_microwave_90_watt": "90 Watt",
@@ -1555,6 +1583,12 @@
"dishcare_dishwasher_program_quick_65": "Quick 65ºC",
"dishcare_dishwasher_program_steam_fresh": "Steam fresh",
"dishcare_dishwasher_program_super_60": "Super 60ºC",
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "Active clean",
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "Auto",
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "Cool",
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "Dry",
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "Fan",
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "Heat",
"laundry_care_dryer_program_anti_shrink": "Anti shrink",
"laundry_care_dryer_program_blankets": "Blankets",
"laundry_care_dryer_program_business_shirts": "Business shirts",
@@ -1823,6 +1857,14 @@
"description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware.",
"name": "Zeolite dry"
},
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode": {
"description": "Setting to adjust the fan speed mode to Manual or Auto.",
"name": "Fan speed mode"
},
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage": {
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
"name": "Fan speed percentage"
},
"laundry_care_dryer_option_drying_target": {
"description": "Describes the drying target for a dryer program.",
"name": "Drying target"
@@ -1854,6 +1896,10 @@
},
"name": "Set program and options",
"sections": {
"air_conditioner_options": {
"description": "Specific settings for air conditioners.",
"name": "Air conditioner options"
},
"cleaning_robot_options": {
"description": "Options for cleaning robots.",
"name": "Cleaning robot options"

View File

@@ -62,6 +62,10 @@
},
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_otbr_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
@@ -204,6 +208,10 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -35,3 +35,5 @@ ZIGBEE_FLASHER_ADDON_SLUG = "core_silabs_flasher"
SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol"
SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher"
Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html"

View File

@@ -33,7 +33,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
@@ -456,7 +456,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._hardware_name is not None
if self._zigbee_integration == ZigbeeIntegration.OTHER:
return self._async_flow_finished()
return await self.async_step_show_z2m_docs_url()
result = await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
@@ -475,6 +475,21 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
)
return self._continue_zha_flow(result)
async def async_step_show_z2m_docs_url(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show Zigbee2MQTT documentation link."""
if user_input is not None:
return self._async_flow_finished()
return self.async_show_form(
step_id="show_z2m_docs_url",
description_placeholders={
**self._get_translation_placeholders(),
"z2m_docs_url": Z2M_EMBER_DOCS_URL,
},
)
@callback
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
"""Continue the ZHA flow."""

View File

@@ -53,6 +53,10 @@
},
"title": "Pick your protocol"
},
"show_z2m_docs_url": {
"description": "Your {model} is now running the latest Zigbee firmware.\nPlease read the Zigbee2MQTT documentation for EmberZNet adapters and copy the config for your {model}: {z2m_docs_url}",
"title": "Set up Zigbee2MQTT"
},
"start_otbr_addon": {
"title": "Configuring Thread"
},

View File

@@ -62,6 +62,10 @@
},
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_otbr_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
@@ -204,6 +208,10 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -138,6 +138,10 @@
"reconfigure_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"show_z2m_docs_url": {
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
},
"start_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
},

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
ContactInterface,
@@ -87,8 +88,11 @@ async def async_setup_entry(
entities.append(HomematicipTiltVibrationSensor(hap, device))
if isinstance(device, WiredInput32):
entities.extend(
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 33)
HomematicipMultiContactInterface(
hap, device, channel_real_index=channel.index
)
for channel in device.functionalChannels
if isinstance(channel, MultiModeInputChannel)
)
elif isinstance(device, FullFlushContactInterface6):
entities.extend(
@@ -227,21 +231,24 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt
device,
channel=1,
is_multi_channel=True,
channel_real_index=None,
) -> None:
"""Initialize the multi contact entity."""
super().__init__(
hap, device, channel=channel, is_multi_channel=is_multi_channel
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
channel_real_index=channel_real_index,
)
@property
def is_on(self) -> bool | None:
"""Return true if the contact interface is on/open."""
if self._device.functionalChannels[self._channel].windowState is None:
channel = self.get_channel_or_raise()
if channel.windowState is None:
return None
return (
self._device.functionalChannels[self._channel].windowState
!= WindowState.CLOSED
)
return channel.windowState != WindowState.CLOSED
class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensorEntity):

View File

@@ -283,19 +283,23 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
return self.functional_channel.doorState == DoorState.CLOSED
channel = self.get_channel_or_raise()
return channel.doorState == DoorState.CLOSED
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
channel = self.get_channel_or_raise()
await channel.async_send_door_command(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import logging
from typing import Any
@@ -84,6 +85,7 @@ class HomematicipGenericEntity(Entity):
post: str | None = None,
channel: int | None = None,
is_multi_channel: bool | None = False,
channel_real_index: int | None = None,
) -> None:
"""Initialize the generic entity."""
self._hap = hap
@@ -91,8 +93,19 @@ class HomematicipGenericEntity(Entity):
self._device = device
self._post = post
self._channel = channel
# channel_real_index represents the actual index of the devices channel.
# Accessing a functionalChannel by the channel parameter or array index is unreliable,
# because the functionalChannels array is sorted as strings, not numbers.
# For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ...
# Using channel_real_index ensures you reference the correct channel.
self._channel_real_index: int | None = channel_real_index
self._is_multi_channel = is_multi_channel
self.functional_channel = self.get_current_channel()
self.functional_channel = None
with contextlib.suppress(ValueError):
self.functional_channel = self.get_current_channel()
# Marker showing that the HmIP device hase been removed.
self.hmip_device_removed = False
@@ -101,17 +114,20 @@ class HomematicipGenericEntity(Entity):
"""Return device specific attributes."""
# Only physical devices should be HA devices.
if isinstance(self._device, Device):
device_id = str(self._device.id)
home_id = str(self._device.homeId)
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
(DOMAIN, self._device.id)
(DOMAIN, device_id)
},
manufacturer=self._device.oem,
model=self._device.modelType,
name=self._device.label,
sw_version=self._device.firmwareVersion,
# Link to the homematic ip access point.
via_device=(DOMAIN, self._device.homeId),
via_device=(DOMAIN, home_id),
)
return None
@@ -185,25 +201,31 @@ class HomematicipGenericEntity(Entity):
def name(self) -> str:
"""Return the name of the generic entity."""
name = None
name = ""
# Try to get a label from a channel.
if hasattr(self._device, "functionalChannels"):
functional_channels = getattr(self._device, "functionalChannels", None)
if functional_channels and self.functional_channel:
if self._is_multi_channel:
name = self._device.functionalChannels[self._channel].label
elif len(self._device.functionalChannels) > 1:
name = self._device.functionalChannels[1].label
label = getattr(self.functional_channel, "label", None)
if label:
name = str(label)
elif len(functional_channels) > 1:
label = getattr(functional_channels[1], "label", None)
if label:
name = str(label)
# Use device label, if name is not defined by channel label.
if not name:
name = self._device.label
name = self._device.label or ""
if self._post:
name = f"{name} {self._post}"
elif self._is_multi_channel:
name = f"{name} Channel{self._channel}"
name = f"{name} Channel{self.get_channel_index()}"
# Add a prefix to the name if the homematic ip home has a name.
if name and self._home.name:
name = f"{self._home.name} {name}"
home_name = getattr(self._home, "name", None)
if name and home_name:
name = f"{home_name} {name}"
return name
@@ -217,9 +239,7 @@ class HomematicipGenericEntity(Entity):
"""Return a unique ID."""
unique_id = f"{self.__class__.__name__}_{self._device.id}"
if self._is_multi_channel:
unique_id = (
f"{self.__class__.__name__}_Channel{self._channel}_{self._device.id}"
)
unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}"
return unique_id
@@ -254,12 +274,65 @@ class HomematicipGenericEntity(Entity):
return state_attr
def get_current_channel(self) -> FunctionalChannel:
"""Return the FunctionalChannel for device."""
if hasattr(self._device, "functionalChannels"):
if self._is_multi_channel:
return self._device.functionalChannels[self._channel]
"""Return the FunctionalChannel for the device.
if len(self._device.functionalChannels) > 1:
return self._device.functionalChannels[1]
Resolution priority:
1. For multi-channel entities with a real index, find channel by index match.
2. For multi-channel entities without a real index, use the provided channel position.
3. For non multi-channel entities with >1 channels, use channel at position 1
(index 0 is often a meta/service channel in HmIP).
Raises ValueError if no suitable channel can be resolved.
"""
functional_channels = getattr(self._device, "functionalChannels", None)
if not functional_channels:
raise ValueError(
f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels"
)
return None
# Multi-channel handling
if self._is_multi_channel:
# Prefer real index mapping when provided to avoid ordering issues.
if self._channel_real_index is not None:
for channel in functional_channels:
if channel.index == self._channel_real_index:
return channel
raise ValueError(
f"Real channel index {self._channel_real_index} not found for device "
f"{getattr(self._device, 'id', 'unknown')}"
)
# Fallback: positional channel (already sorted as strings upstream).
if self._channel is not None and 0 <= self._channel < len(
functional_channels
):
return functional_channels[self._channel]
raise ValueError(
f"Channel position {self._channel} invalid for device "
f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})"
)
# Single-channel / non multi-channel entity: choose second element if available
if len(functional_channels) > 1:
return functional_channels[1]
return functional_channels[0]
def get_channel_index(self) -> int:
"""Return the correct channel index for this entity.
Prefers channel_real_index if set, otherwise returns channel.
This ensures the correct channel is used even if the functionalChannels list is not numerically ordered.
"""
if self._channel_real_index is not None:
return self._channel_real_index
if self._channel is not None:
return self._channel
return 1
def get_channel_or_raise(self) -> FunctionalChannel:
"""Return the FunctionalChannel or raise an error if not found."""
if not self.functional_channel:
raise ValueError(
f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}"
)
return self.functional_channel

View File

@@ -92,7 +92,9 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self.functional_channel.add_on_channel_event_handler(self._async_handle_event)
channel = self.get_channel_or_raise()
channel.add_on_channel_event_handler(self._async_handle_event)
@callback
def _async_handle_event(self, *args, **kwargs) -> None:

View File

@@ -134,49 +134,49 @@ class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self.functional_channel.on
channel = self.get_channel_or_raise()
return channel.on
@property
def brightness(self) -> int | None:
"""Return the current brightness."""
return int(self.functional_channel.dimLevel * 255.0)
channel = self.get_channel_or_raise()
return int(channel.dimLevel * 255.0)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
if (
self.functional_channel.hue is None
or self.functional_channel.saturationLevel is None
):
channel = self.get_channel_or_raise()
if channel.hue is None or channel.saturationLevel is None:
return None
return (
self.functional_channel.hue,
self.functional_channel.saturationLevel * 100.0,
channel.hue,
channel.saturationLevel * 100.0,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
channel = self.get_channel_or_raise()
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
if ATTR_HS_COLOR not in kwargs:
hue = self.functional_channel.hue
saturation = self.functional_channel.saturationLevel
hue = channel.hue
saturation = channel.saturationLevel
if ATTR_BRIGHTNESS not in kwargs:
# If no brightness is set, use the current brightness
dim_level = self.functional_channel.dimLevel or 1.0
await self.functional_channel.set_hue_saturation_dim_level_async(
dim_level = channel.dimLevel or 1.0
await channel.set_hue_saturation_dim_level_async(
hue=hue, saturation_level=saturation, dim_level=dim_level
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.functional_channel.set_switch_state_async(on=False)
channel = self.get_channel_or_raise()
await channel.set_switch_state_async(on=False)
class HomematicipLightMeasuring(HomematicipLight):

View File

@@ -307,7 +307,8 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity):
@property
def native_value(self) -> float | None:
"""Return the state."""
return self.functional_channel.waterFlow
channel = self.get_channel_or_raise()
return channel.waterFlow
class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):

View File

@@ -113,15 +113,18 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
return self.functional_channel.on
channel = self.get_channel_or_raise()
return channel.on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.functional_channel.async_turn_on()
channel = self.get_channel_or_raise()
await channel.async_turn_on()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.functional_channel.async_turn_off()
channel = self.get_channel_or_raise()
await channel.async_turn_off()
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):

View File

@@ -47,13 +47,16 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity):
async def async_open_valve(self) -> None:
"""Open the valve."""
await self.functional_channel.set_watering_switch_state_async(True)
channel = self.get_channel_or_raise()
await channel.set_watering_switch_state_async(True)
async def async_close_valve(self) -> None:
"""Close valve."""
await self.functional_channel.set_watering_switch_state_async(False)
channel = self.get_channel_or_raise()
await channel.set_watering_switch_state_async(False)
@property
def is_closed(self) -> bool:
"""Return if the valve is closed."""
return self.functional_channel.wateringActive is False
channel = self.get_channel_or_raise()
return channel.wateringActive is False

View File

@@ -11,5 +11,5 @@
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
"quality_scale": "platinum",
"requirements": ["incomfort-client==0.6.9"]
"requirements": ["incomfort-client==0.6.10"]
}

View File

@@ -11,11 +11,7 @@ from random import random
import voluptuous as vol
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
EventLabsUpdatedData,
async_is_preview_feature_enabled,
)
from homeassistant.components.labs import async_is_preview_feature_enabled, async_listen
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import (
StatisticData,
@@ -35,7 +31,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import (
@@ -120,17 +116,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
# Subscribe to labs feature updates for kitchen_sink preview repair
@callback
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == "kitchen_sink"
and event.data["preview_feature"] == "special_repair"
):
_async_update_special_repair(hass)
entry.async_on_unload(
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
async_listen(
hass,
domain=DOMAIN,
preview_feature="special_repair",
listener=lambda: _async_update_special_repair(hass),
)
)
# Check if lab feature is currently enabled and create repair if so

View File

@@ -7,6 +7,7 @@ in the Home Assistant Labs UI for users to enable or disable.
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any
@@ -14,7 +15,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.backup import async_get_manager
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
@@ -41,6 +42,7 @@ __all__ = [
"EVENT_LABS_UPDATED",
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
"async_listen",
]
@@ -217,6 +219,37 @@ def async_is_preview_feature_enabled(
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
@callback
def async_listen(
hass: HomeAssistant,
domain: str,
preview_feature: str,
listener: Callable[[], None],
) -> Callable[[], None]:
"""Listen for changes to a specific preview feature.
Args:
hass: HomeAssistant instance
domain: Integration domain
preview_feature: Preview feature name
listener: Callback to invoke when the preview feature is toggled
Returns:
Callable to unsubscribe from the listener
"""
@callback
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
"""Handle labs feature update event."""
if (
event.data["domain"] == domain
and event.data["preview_feature"] == preview_feature
):
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
@@ -234,7 +267,7 @@ def websocket_list_preview_features(
(preview_feature.domain, preview_feature.preview_feature)
in labs_data.data["preview_feature_status"]
)
for preview_feature_key, preview_feature in labs_data.preview_features.items()
for preview_feature in labs_data.preview_features.values()
if preview_feature.domain in loaded_components
]

View File

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

View File

@@ -287,7 +287,11 @@ class DashboardsCollection(collection.DictStorageCollection):
raise vol.Invalid("Url path needs to contain a hyphen (-)")
if url_path in self.hass.data[DATA_PANELS]:
raise vol.Invalid("Panel url path needs to be unique")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="url_already_exists",
translation_placeholders={"url": url_path},
)
return self.CREATE_SCHEMA(data) # type: ignore[no-any-return]

View File

@@ -1,4 +1,9 @@
{
"exceptions": {
"url_already_exists": {
"message": "The URL \"{url}\" is already in use. Please choose a different one."
}
},
"services": {
"reload_resources": {
"description": "Reloads dashboard resources from the YAML-configuration.",

View File

@@ -25,6 +25,7 @@ async def async_get_config_entry_diagnostics(
"scenes": bridge.scenes,
"occupancy_groups": bridge.occupancy_groups,
"areas": bridge.areas,
"smart_away_state": bridge.smart_away_state,
},
"integration_data": {
"keypad_button_names_to_leap": data.keypad_data.button_names_to_leap,

View File

@@ -5,9 +5,12 @@ from typing import Any
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import LutronCasetaUpdatableEntity
from .const import DOMAIN
from .entity import LutronCasetaEntity, LutronCasetaUpdatableEntity
from .models import LutronCasetaData
async def async_setup_entry(
@@ -23,9 +26,14 @@ async def async_setup_entry(
data = config_entry.runtime_data
bridge = data.bridge
switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN)
async_add_entities(
entities: list[LutronCasetaLight | LutronCasetaSmartAwaySwitch] = [
LutronCasetaLight(switch_device, data) for switch_device in switch_devices
)
]
if bridge.smart_away_state != "":
entities.append(LutronCasetaSmartAwaySwitch(data))
async_add_entities(entities)
class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
@@ -61,3 +69,46 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
def is_on(self) -> bool:
"""Return true if device is on."""
return self._device["current_state"] > 0
class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
"""Representation of Lutron Caseta Smart Away."""
def __init__(self, data: LutronCasetaData) -> None:
"""Init a switch entity."""
device = {
"device_id": "smart_away",
"name": "Smart Away",
"type": "SmartAway",
"model": "Smart Away",
"area": data.bridge_device["area"],
"serial": data.bridge_device["serial"],
}
super().__init__(device, data)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.bridge_device["serial"])},
)
self._smart_away_unique_id = f"{self._bridge_unique_id}_smart_away"
@property
def unique_id(self) -> str:
"""Return the unique ID of the smart away switch."""
return self._smart_away_unique_id
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
self._smartbridge.add_smart_away_subscriber(self._handle_bridge_update)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn Smart Away on."""
await self._smartbridge.activate_smart_away()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn Smart Away off."""
await self._smartbridge.deactivate_smart_away()
@property
def is_on(self) -> bool:
"""Return true if Smart Away is on."""
return self._smartbridge.smart_away_state == "Enabled"

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0"]
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0", "aiofiles==24.1.0"]
}

View File

@@ -40,6 +40,7 @@ from homeassistant.util.async_ import create_eager_task
from . import debug_info, discovery
from .client import (
MQTT,
async_on_subscribe_done,
async_publish,
async_subscribe,
async_subscribe_internal,
@@ -163,6 +164,7 @@ __all__ = [
"async_create_certificate_temp_files",
"async_forward_entry_setup_and_setup_discovery",
"async_migrate_entry",
"async_on_subscribe_done",
"async_prepare_subscribe_topics",
"async_publish",
"async_remove_config_entry_device",

View File

@@ -38,7 +38,10 @@ from homeassistant.core import (
get_hassjob_callable_job_type,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
@@ -71,6 +74,7 @@ from .const import (
DEFAULT_WS_PATH,
DOMAIN,
MQTT_CONNECTION_STATE,
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
TRANSPORT_WEBSOCKETS,
@@ -109,6 +113,7 @@ INITIAL_SUBSCRIBE_COOLDOWN = 0.5
SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
SUBSCRIBE_TIMEOUT = 10
RECONNECT_INTERVAL_SECONDS = 10
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
@@ -184,6 +189,38 @@ async def async_publish(
)
@callback
def async_on_subscribe_done(
hass: HomeAssistant,
topic: str,
qos: int,
on_subscribe_status: CALLBACK_TYPE,
) -> CALLBACK_TYPE:
"""Call on_subscribe_done when the matched subscription was completed.
If a subscription is already present the callback will call
on_subscribe_status directly.
Call the returned callback to stop and cleanup status monitoring.
"""
async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None:
if (topic, qos) not in subscriptions:
return
hass.loop.call_soon(on_subscribe_status)
mqtt_data = hass.data[DATA_MQTT]
if (
mqtt_data.client.connected
and mqtt_data.client.is_active_subscription(topic)
and not mqtt_data.client.is_pending_subscription(topic)
):
hass.loop.call_soon(on_subscribe_status)
return async_dispatcher_connect(
hass, MQTT_PROCESSED_SUBSCRIPTIONS, _sync_mqtt_subscribe
)
@bind_hass
async def async_subscribe(
hass: HomeAssistant,
@@ -191,12 +228,32 @@ async def async_subscribe(
msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None],
qos: int = DEFAULT_QOS,
encoding: str | None = DEFAULT_ENCODING,
on_subscribe: CALLBACK_TYPE | None = None,
) -> CALLBACK_TYPE:
"""Subscribe to an MQTT topic.
If the on_subcribe callback hook is set, it will be called once
when the subscription has been completed.
Call the return value to unsubscribe.
"""
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
handler: CALLBACK_TYPE | None = None
def _on_subscribe_done() -> None:
"""Call once when the subscription was completed."""
if TYPE_CHECKING:
assert on_subscribe is not None and handler is not None
handler()
on_subscribe()
subscription_handler = async_subscribe_internal(
hass, topic, msg_callback, qos, encoding
)
if on_subscribe is not None:
handler = async_on_subscribe_done(hass, topic, qos, _on_subscribe_done)
return subscription_handler
@callback
@@ -640,12 +697,16 @@ class MQTT:
if fileno > -1:
self.loop.remove_writer(sock)
def _is_active_subscription(self, topic: str) -> bool:
def is_active_subscription(self, topic: str) -> bool:
"""Check if a topic has an active subscription."""
return topic in self._simple_subscriptions or any(
other.topic == topic for other in self._wildcard_subscriptions
)
def is_pending_subscription(self, topic: str) -> bool:
"""Check if a topic has a pending subscription."""
return topic in self._pending_subscriptions
async def async_publish(
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
) -> None:
@@ -899,7 +960,7 @@ class MQTT:
@callback
def _async_unsubscribe(self, topic: str) -> None:
"""Unsubscribe from a topic."""
if self._is_active_subscription(topic):
if self.is_active_subscription(topic):
if self._max_qos[topic] == 0:
return
subs = self._matching_subscriptions(topic)
@@ -963,6 +1024,7 @@ class MQTT:
self._last_subscribe = time.monotonic()
await self._async_wait_for_mid_or_raise(mid, result)
async_dispatcher_send(self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, chunk_list)
async def _async_perform_unsubscribes(self) -> None:
"""Perform pending MQTT client unsubscribes."""

View File

@@ -62,6 +62,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.components.valve import ValveDeviceClass, ValveState
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -276,6 +277,7 @@ from .const import (
CONF_PRESET_MODES_LIST,
CONF_QOS,
CONF_RED_TEMPLATE,
CONF_REPORTS_POSITION,
CONF_RETAIN,
CONF_RGB_COMMAND_TEMPLATE,
CONF_RGB_COMMAND_TOPIC,
@@ -467,6 +469,7 @@ SUBENTRY_PLATFORMS = [
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.VALVE,
]
_CODE_VALIDATION_MODE = {
@@ -831,6 +834,16 @@ TEXT_MODE_SELECTOR = SelectSelector(
TEXT_SIZE_SELECTOR = NumberSelector(
NumberSelectorConfig(min=0, max=255, step=1, mode=NumberSelectorMode.BOX)
)
VALVE_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in ValveDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class_valve",
)
)
VALVE_POSITION_SELECTOR = NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, step=1)
)
@callback
@@ -1199,6 +1212,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.SIREN: None,
Platform.SWITCH: None,
Platform.TEXT: validate_text_platform_config,
Platform.VALVE: None,
}
@@ -1460,6 +1474,16 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
),
},
Platform.TEXT: {},
Platform.VALVE: {
CONF_DEVICE_CLASS: PlatformField(
selector=VALVE_DEVICE_CLASS_SELECTOR, required=False, default=None
),
CONF_REPORTS_POSITION: PlatformField(
selector=BOOLEAN_SELECTOR,
required=True,
default=False,
),
},
}
PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
Platform.ALARM_CONTROL_PANEL: {
@@ -3380,6 +3404,91 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
section="text_advanced_settings",
),
},
Platform.VALVE: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_POSITION_CLOSED: PlatformField(
selector=VALVE_POSITION_SELECTOR,
required=True,
default=DEFAULT_POSITION_CLOSED,
conditions=({CONF_REPORTS_POSITION: True},),
),
CONF_POSITION_OPEN: PlatformField(
selector=VALVE_POSITION_SELECTOR,
required=True,
default=DEFAULT_POSITION_OPEN,
conditions=({CONF_REPORTS_POSITION: True},),
),
CONF_PAYLOAD_OPEN: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=DEFAULT_PAYLOAD_OPEN,
conditions=({CONF_REPORTS_POSITION: False},),
section="valve_payload_settings",
),
CONF_PAYLOAD_CLOSE: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=DEFAULT_PAYLOAD_CLOSE,
conditions=({CONF_REPORTS_POSITION: False},),
section="valve_payload_settings",
),
CONF_PAYLOAD_STOP: PlatformField(
selector=TEXT_SELECTOR,
required=False,
section="valve_payload_settings",
),
CONF_STATE_OPEN: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=ValveState.OPEN.value,
conditions=({CONF_REPORTS_POSITION: False},),
section="valve_payload_settings",
),
CONF_STATE_CLOSED: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=ValveState.CLOSED.value,
conditions=({CONF_REPORTS_POSITION: False},),
section="valve_payload_settings",
),
CONF_STATE_OPENING: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=ValveState.OPENING.value,
section="valve_payload_settings",
),
CONF_STATE_CLOSING: PlatformField(
selector=TEXT_SELECTOR,
required=True,
default=ValveState.CLOSING.value,
section="valve_payload_settings",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
}
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),

View File

@@ -172,6 +172,7 @@ CONF_PRESET_MODES_LIST = "preset_modes"
CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_RED_TEMPLATE = "red_template"
CONF_REPORTS_POSITION = "reports_position"
CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template"
CONF_RGB_COMMAND_TOPIC = "rgb_command_topic"
CONF_RGB_STATE_TOPIC = "rgb_state_topic"
@@ -375,6 +376,7 @@ DOMAIN = "mqtt"
LOGGER = logging.getLogger(__package__)
MQTT_CONNECTION_STATE = "mqtt_connection_state"
MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"

View File

@@ -242,6 +242,7 @@
"fan_feature_speed": "Speed support",
"image_processing_mode": "Image processing mode",
"options": "Add option",
"reports_position": "Reports position",
"schema": "Schema",
"state_class": "State class",
"suggested_display_precision": "Suggested display precision",
@@ -269,6 +270,7 @@
"fan_feature_speed": "The fan supports multiple speeds.",
"image_processing_mode": "Select how the image data is received.",
"options": "Options for allowed sensor state values. The sensors Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.",
"reports_position": "Set this option if the valve reports the position or supports setting the position. Enabling this option will cause the position to be published instead of a payload defined by payload \"open\", payload \"close\" or payload \"stop\". When receiving messages, state topic will accept numeric payloads or one of the configured state messages.",
"schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)",
"state_class": "The [State class]({available_state_classes_url}) of the sensor. [Learn more.]({url}#state_class)",
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
@@ -356,6 +358,8 @@
"payload_on": "Payload \"on\"",
"payload_press": "Payload \"press\"",
"payload_reset": "Payload \"reset\"",
"position_closed": "Position \"closed\"",
"position_open": "Position \"open\"",
"qos": "QoS",
"red_template": "Red template",
"retain": "Retain",
@@ -407,6 +411,8 @@
"payload_on": "The payload that represents the \"on\" state.",
"payload_press": "The payload to send when the button is triggered.",
"payload_reset": "The payload received at the state topic that resets the entity to an unknown state.",
"position_closed": "Number which represents closed position. The valves position will be scaled to the (position \"closed\"…position \"open\") range when an action is performed and scaled back when a value is received.",
"position_open": "Number which represents open position. The valves position will be scaled to the (position \"closed\"…position \"open\") range when an action is performed and scaled back when a value is received.",
"qos": "The QoS value a {platform} entity should use.",
"red_template": "[Template]({value_templating_url}) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.",
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
@@ -985,6 +991,27 @@
"pattern": "A valid regex pattern"
},
"name": "Advanced text entity settings"
},
"valve_payload_settings": {
"data": {
"payload_close": "Payload \"close\"",
"payload_open": "Payload \"open\"",
"payload_stop": "Payload \"stop\"",
"state_closed": "State \"closed\"",
"state_closing": "State \"closing\"",
"state_open": "State \"open\"",
"state_opening": "State \"opening\""
},
"data_description": {
"payload_close": "The payload sent when a \"close\" command is issued.",
"payload_open": "The payload sent when an \"open\" command is issued.",
"payload_stop": "The payload sent when a \"stop\" command is issued. Set this payload only if your valve supports the \"stop\" action.",
"state_closed": "The payload received at the state topic that represents the \"closed\" state.",
"state_closing": "The payload received at the state topic that represents the \"closing\" state.",
"state_open": "The payload received at the state topic that represents the \"open\" state.",
"state_opening": "The payload received at the state topic that represents the \"opening\" state."
},
"name": "Valve payload settings"
}
},
"title": "Configure MQTT device \"{mqtt_device}\""
@@ -1347,6 +1374,12 @@
"switch": "[%key:component::switch::title%]"
}
},
"device_class_valve": {
"options": {
"gas": "[%key:component::valve::entity_component::gas::name%]",
"water": "[%key:component::valve::entity_component::water::name%]"
}
},
"entity_category": {
"options": {
"config": "Config",
@@ -1403,7 +1436,8 @@
"sensor": "[%key:component::sensor::title%]",
"siren": "[%key:component::siren::title%]",
"switch": "[%key:component::switch::title%]",
"text": "[%key:component::text::title%]"
"text": "[%key:component::text::title%]",
"valve": "[%key:component::valve::title%]"
}
},
"set_ca_cert": {

View File

@@ -42,6 +42,7 @@ from .const import (
CONF_PAYLOAD_STOP,
CONF_POSITION_CLOSED,
CONF_POSITION_OPEN,
CONF_REPORTS_POSITION,
CONF_RETAIN,
CONF_STATE_CLOSED,
CONF_STATE_CLOSING,
@@ -65,8 +66,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
CONF_REPORTS_POSITION = "reports_position"
DEFAULT_NAME = "MQTT Valve"
MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset(
@@ -112,8 +111,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None),
vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None),
vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None),
vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int,
vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int,
vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): vol.Coerce(
int
),
vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): vol.Coerce(
int
),
vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean,
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
vol.Optional(CONF_STATE_CLOSED): cv.string,

View File

@@ -40,7 +40,7 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available OctoPrint binary sensors."""
"""Set up the available OctoPrint sensors."""
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]["coordinator"]
@@ -111,7 +111,7 @@ class OctoPrintSensorBase(
class OctoPrintStatusSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint status sensor."""
_attr_icon = "mdi:printer-3d"
@@ -137,7 +137,7 @@ class OctoPrintStatusSensor(OctoPrintSensorBase):
class OctoPrintJobPercentageSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint job percentage sensor."""
_attr_native_unit_of_measurement = PERCENTAGE
_attr_icon = "mdi:file-percent"
@@ -162,9 +162,10 @@ class OctoPrintJobPercentageSensor(OctoPrintSensorBase):
class OctoPrintEstimatedFinishTimeSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint estimated finish time sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:clock-end"
def __init__(
self, coordinator: OctoprintDataUpdateCoordinator, device_id: str
@@ -191,9 +192,10 @@ class OctoPrintEstimatedFinishTimeSensor(OctoPrintSensorBase):
class OctoPrintStartTimeSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint start time sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:clock-start"
def __init__(
self, coordinator: OctoprintDataUpdateCoordinator, device_id: str
@@ -221,11 +223,12 @@ class OctoPrintStartTimeSensor(OctoPrintSensorBase):
class OctoPrintTemperatureSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint temperature sensor."""
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:printer-3d-nozzle-heat"
def __init__(
self,
@@ -267,7 +270,9 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase):
class OctoPrintFileNameSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint file name sensor."""
_attr_icon = "mdi:printer-3d-nozzle"
def __init__(
self,
@@ -294,7 +299,7 @@ class OctoPrintFileNameSensor(OctoPrintSensorBase):
class OctoPrintFileSizeSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
"""Representation of an OctoPrint file size sensor."""
_attr_device_class = SensorDeviceClass.DATA_SIZE
_attr_native_unit_of_measurement = UnitOfInformation.BYTES

View File

@@ -306,6 +306,14 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow):
async_step_reconfigure = async_step_set_options
def filter_invalid_llm_apis(hass: HomeAssistant, selected_apis: list[str]) -> list[str]:
"""Accepts a list of LLM API IDs and filters this against those currently available."""
valid_llm_apis = [api.id for api in llm.async_get_apis(hass)]
return [api for api in selected_apis if api in valid_llm_apis]
def ollama_config_option_schema(
hass: HomeAssistant,
is_new: bool,
@@ -326,6 +334,10 @@ def ollama_config_option_schema(
else:
schema = {}
selected_llm_apis = filter_invalid_llm_apis(
hass, options.get(CONF_LLM_HASS_API, [])
)
schema.update(
{
vol.Required(
@@ -349,7 +361,7 @@ def ollama_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
description={"suggested_value": selected_llm_apis},
): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -2,19 +2,19 @@
from __future__ import annotations
import logging
from pysaunum import SaunumClient, SaunumConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .coordinator import LeilSaunaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.LIGHT,
]
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
@@ -27,8 +26,6 @@ from . import LeilSaunaConfigEntry
from .const import DELAYED_REFRESH_SECONDS, DOMAIN
from .entity import LeilSaunaEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
# Map Saunum fan speed (0-3) to Home Assistant fan modes

View File

@@ -3,14 +3,7 @@
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "saunum"
# Platforms
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
]
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)

View File

@@ -0,0 +1,71 @@
"""Light platform for Saunum Leil Sauna Control Unit."""
from __future__ import annotations
from typing import Any
from pysaunum import SaunumException
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .const import DOMAIN
from .entity import LeilSaunaEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna light entity."""
coordinator = entry.runtime_data
async_add_entities([LeilSaunaLight(coordinator)])
class LeilSaunaLight(LeilSaunaEntity, LightEntity):
"""Representation of a Saunum Leil Sauna light entity."""
_attr_translation_key = "light"
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__(self, coordinator) -> None:
"""Initialize the light entity."""
super().__init__(coordinator)
# Override unique_id to differentiate from climate entity
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light"
@property
def is_on(self) -> bool | None:
"""Return True if light is on."""
return self.coordinator.data.light_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
try:
await self.coordinator.client.async_set_light_control(True)
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_on_failed",
) from err
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
try:
await self.coordinator.client.async_set_light_control(False)
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_off_failed",
) from err
await self.coordinator.async_request_refresh()

View File

@@ -59,7 +59,7 @@ rules:
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo

View File

@@ -19,6 +19,13 @@
}
}
},
"entity": {
"light": {
"light": {
"name": "[%key:component::light::title%]"
}
}
},
"exceptions": {
"communication_error": {
"message": "Communication error: {error}"
@@ -29,6 +36,12 @@
"set_hvac_mode_failed": {
"message": "Failed to set HVAC mode to {hvac_mode}"
},
"set_light_off_failed": {
"message": "Failed to turn off light"
},
"set_light_on_failed": {
"message": "Failed to turn on light"
},
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}"
}

View File

@@ -60,6 +60,7 @@ from .coordinator import (
from .repairs import (
async_manage_ble_scanner_firmware_unsupported_issue,
async_manage_deprecated_firmware_issue,
async_manage_open_wifi_ap_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
)
from .utils import (
@@ -347,6 +348,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
hass,
entry,
)
async_manage_open_wifi_ap_issue(hass, entry)
remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None

View File

@@ -33,6 +33,7 @@ from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_clear_address_from_match_history,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
@@ -395,6 +396,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if not mac:
return self.async_abort(reason="invalid_discovery_info")
# Clear match history at the start of discovery flow.
# This ensures that if the user never provisions the device and it
# disappears (powers down), the discovery flow gets cleaned up,
# and then the device comes back later, it can be rediscovered.
# Also handles factory reset scenarios where the device may reappear
# with different advertisement content (RPC-over-BLE re-enabled).
async_clear_address_from_match_history(self.hass, discovery_info.address)
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
if not has_rpc_over_ble(discovery_info.manufacturer_data):
LOGGER.debug(
@@ -685,6 +694,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
# Secure device after provisioning if requested (disable AP/BLE)
await self._async_secure_device_after_provision(self.host, self.port)
# Clear match history so device can be rediscovered if factory reset
# This ensures that if the device is factory reset in the future
# (re-enabling BLE provisioning), it will trigger a new discovery flow
if TYPE_CHECKING:
assert self.ble_device is not None
async_clear_address_from_match_history(self.hass, self.ble_device.address)
# User just provisioned this device - create entry directly without confirmation
return self.async_create_entry(
title=device_info["title"],

View File

@@ -254,6 +254,7 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
"outbound_websocket_incorrectly_enabled_{unique}"
)
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
class DeprecatedFirmwareInfo(TypedDict):

View File

@@ -20,14 +20,14 @@ from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, ROLE_GENERIC
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .utils import (
async_remove_shelly_entity,
get_block_device_info,
get_block_entity_name,
get_rpc_channel_name,
get_rpc_device_info,
get_rpc_entity_name,
get_rpc_key,
get_rpc_key_instances,
get_rpc_role_by_key,
)
@@ -371,7 +371,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.block = block
self._attr_name = get_block_entity_name(coordinator.device, block)
self._attr_device_info = get_entity_block_device_info(coordinator, block)
self._attr_unique_id = f"{coordinator.mac}-{block.description}"
@@ -413,9 +413,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.key = key
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key)
@property
def available(self) -> bool:
@@ -467,9 +467,6 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
self.entity_description = description
self._attr_unique_id: str = f"{super().unique_id}-{self.attribute}"
self._attr_name = get_block_entity_name(
coordinator.device, block, description.name
)
@property
def attribute_value(self) -> StateType:
@@ -507,9 +504,7 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
self.block_coordinator = coordinator
self.attribute = attribute
self.entity_description = description
self._attr_name = get_block_entity_name(
coordinator.device, None, description.name
)
self._attr_unique_id = f"{coordinator.mac}-{attribute}"
self._attr_device_info = get_entity_block_device_info(coordinator)
self._last_value = None
@@ -546,13 +541,13 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity):
self.attribute = attribute
self.entity_description = description
if description.role == ROLE_GENERIC:
self._attr_name = get_rpc_channel_name(coordinator.device, key)
self._attr_unique_id = f"{super().unique_id}-{attribute}"
self._attr_name = get_rpc_entity_name(
coordinator.device, key, description.name, description.role
)
self._last_value = None
id_key = key.split(":")[-1]
self._id = int(id_key) if id_key.isnumeric() else None
has_id, _, component_id = get_rpc_key(key)
self._id = int(component_id) if has_id and component_id.isnumeric() else None
if description.unit is not None:
self._attr_native_unit_of_measurement = description.unit(
@@ -626,9 +621,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
self._attr_unique_id = (
f"{self.coordinator.mac}-{block.description}-{attribute}"
)
self._attr_name = get_block_entity_name(
coordinator.device, block, description.name
)
elif entry is not None:
self._attr_unique_id = entry.unique_id
@@ -689,11 +681,7 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}"
self._last_value = None
if coordinator.device.initialized:
self._attr_name = get_rpc_entity_name(
coordinator.device, key, description.name
)
elif entry is not None:
if not coordinator.device.initialized and entry is not None:
self._attr_name = cast(str, entry.original_name)
async def async_update(self) -> None:

View File

@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.20.0"],
"requirements": ["aioshelly==13.21.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -22,6 +22,7 @@ from .const import (
DEPRECATED_FIRMWARE_ISSUE_ID,
DEPRECATED_FIRMWARES,
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
BLEScannerMode,
)
@@ -149,6 +150,45 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
ir.async_delete_issue(hass, DOMAIN, issue_id)
@callback
def async_manage_open_wifi_ap_issue(
hass: HomeAssistant,
entry: ShellyConfigEntry,
) -> None:
"""Manage the open WiFi AP issue."""
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=entry.unique_id)
if TYPE_CHECKING:
assert entry.runtime_data.rpc is not None
device = entry.runtime_data.rpc.device
# Check if WiFi AP is enabled and is open (no password)
if (
(wifi_config := device.config.get("wifi"))
and (ap_config := wifi_config.get("ap"))
and ap_config.get("enable")
and ap_config.get("is_open")
):
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="open_wifi_ap",
translation_placeholders={
"device_name": device.name,
"ip_address": device.ip_address,
},
data={"entry_id": entry.entry_id},
)
return
ir.async_delete_issue(hass, DOMAIN, issue_id)
class ShellyRpcRepairsFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -229,6 +269,49 @@ class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow):
return self.async_create_entry(title="", data={})
class DisableOpenWiFiApFlow(RepairsFlow):
"""Handler for Disable Open WiFi AP flow."""
def __init__(self, device: RpcDevice, issue_id: str) -> None:
"""Initialize."""
self._device = device
self.issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
issue_registry = ir.async_get(self.hass)
description_placeholders = None
if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id):
description_placeholders = issue.translation_placeholders
return self.async_show_menu(
menu_options=["confirm", "ignore"],
description_placeholders=description_placeholders,
)
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
try:
result = await self._device.wifi_setconfig(ap_enable=False)
if result.get("restart_required"):
await self._device.trigger_reboot()
except (DeviceConnectionError, RpcCallError):
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(title="", data={})
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
return self.async_abort(reason="issue_ignored")
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
) -> RepairsFlow:
@@ -253,4 +336,7 @@ async def async_create_fix_flow(
if "outbound_websocket_incorrectly_enabled" in issue_id:
return DisableOutboundWebSocketFlow(device)
if "open_wifi_ap" in issue_id:
return DisableOpenWiFiApFlow(device, issue_id)
return ConfirmRepairFlow()

View File

@@ -59,9 +59,6 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity):
if self.option_map:
self._attr_options = list(self.option_map.values())
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""

View File

@@ -664,6 +664,25 @@
"description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'.",
"title": "Shelly device {device_name} is not calibrated"
},
"open_wifi_ap": {
"fix_flow": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"issue_ignored": "Issue ignored"
},
"step": {
"init": {
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
"menu_options": {
"confirm": "Disable WiFi access point",
"ignore": "Ignore"
},
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
}
}
},
"title": "Open WiFi access point on {device_name}"
},
"outbound_websocket_incorrectly_enabled": {
"fix_flow": {
"abort": {

View File

@@ -49,7 +49,6 @@ from homeassistant.helpers.device_registry import (
DeviceInfo,
)
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util.dt import utcnow
from .const import (
@@ -117,20 +116,6 @@ def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
return channels or 1
def get_block_entity_name(
device: BlockDevice,
block: Block | None,
name: str | UndefinedType | None = None,
) -> str | None:
"""Naming for block based switch and sensors."""
channel_name = get_block_channel_name(device, block)
if name is not UNDEFINED and name:
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get custom name from device settings."""
if block and (key := cast(str, block.type) + "s") and key in device.settings:
@@ -474,23 +459,6 @@ def get_rpc_sub_device_name(
return f"{device.name} {component.title()} {component_id}"
def get_rpc_entity_name(
device: RpcDevice,
key: str,
name: str | UndefinedType | None = None,
role: str | None = None,
) -> str | None:
"""Naming for RPC based switch and sensors."""
channel_name = get_rpc_channel_name(device, key)
if name is not UNDEFINED and name:
if role and role != ROLE_GENERIC:
return name
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_entity_translation_attributes(
channel_name: str | None,
translation_key: str | None,
@@ -826,11 +794,9 @@ async def get_rpc_scripts_event_types(
device: RpcDevice, ignore_scripts: list[str]
) -> dict[int, list[str]]:
"""Return a dict of all scripts and their event types."""
script_instances = get_rpc_key_instances(device.status, "script")
script_events = {}
for script in script_instances:
script_name = get_rpc_entity_name(device, script)
if script_name in ignore_scripts:
for script in get_rpc_key_instances(device.status, "script"):
if get_rpc_channel_name(device, script) in ignore_scripts:
continue
script_id = get_rpc_key_id(script)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["slack"],
"requirements": ["slack_sdk==3.33.4"]
"requirements": ["slack_sdk==3.33.4", "aiofiles==24.1.0"]
}

View File

@@ -166,7 +166,9 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]):
zb_firmware: list[FirmwareList] = []
try:
esp_firmware = await self.client.get_firmware_version(info.fw_channel)
esp_firmware = await self.client.get_firmware_version(
info.fw_channel, device=info.model
)
zb_firmware.extend(
[
await self.client.get_firmware_version(

View File

@@ -12,7 +12,7 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.2.8"],
"requirements": ["pysmlight==0.2.11"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."

View File

@@ -16,6 +16,7 @@ from homeassistant.const import (
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
@@ -164,6 +165,15 @@ WALL_CONNECTOR_SENSORS = [
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
WallConnectorSensorDescription(
key="total_power_w",
translation_key="total_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].total_power_w,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
WallConnectorSensorDescription(
key="session_energy_wh",
translation_key="session_energy_wh",

View File

@@ -75,6 +75,9 @@
"status_code": {
"name": "Status code"
},
"total_power_w": {
"name": "Total power"
},
"voltage_a_v": {
"name": "Phase A voltage"
},

View File

@@ -123,7 +123,9 @@ async def async_setup_entry(
ThermoProBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
class ThermoProBluetoothSensorEntity(

View File

@@ -1,33 +1,83 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DATA_HASS_CONFIG,
DOMAIN,
)
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
GRAPHQL_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
DATA_API_PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TibberGraphQLRuntimeData:
"""Runtime data for GraphQL-based Tibber entries."""
tibber: tibber.Tibber
@dataclass(slots=True)
class TibberDataAPIRuntimeData:
"""Runtime data for Tibber Data API entries."""
session: OAuth2Session
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
hass.data[DATA_HASS_CONFIG] = config
hass.data.setdefault(DOMAIN, {})
async_setup_services(hass)
@@ -37,45 +87,100 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
hass.data.setdefault(DOMAIN, {})
api_type = entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
if api_type == API_TYPE_DATA_API:
return await _async_setup_data_api_entry(hass, entry)
return await _async_setup_graphql_entry(hass, entry)
async def _async_setup_graphql_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the legacy GraphQL Tibber entry."""
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None:
runtime = TibberGraphQLRuntimeData(tibber_connection)
entry.runtime_data = runtime
hass.data[DOMAIN][API_TYPE_GRAPHQL] = runtime
async def _close(_event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as exp:
_LOGGER.error("Failed to login. %s", exp)
except tibber.InvalidLoginError as err:
_LOGGER.error("Failed to login to Tibber GraphQL API: %s", err)
return False
except tibber.FatalHttpExceptionError:
except tibber.FatalHttpExceptionError as err:
_LOGGER.error("Fatal error communicating with Tibber GraphQL API: %s", err)
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, GRAPHQL_PLATFORMS)
return True
async def _async_setup_data_api_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Tibber Data API entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
runtime = TibberDataAPIRuntimeData(session=session)
entry.runtime_data = runtime
hass.data[DOMAIN][API_TYPE_DATA_API] = runtime
await hass.config_entries.async_forward_entry_setups(entry, DATA_API_PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
config_entry,
GRAPHQL_PLATFORMS if api_type == API_TYPE_GRAPHQL else DATA_API_PLATFORMS,
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
if api_type == API_TYPE_GRAPHQL:
runtime = hass.data[DOMAIN].get(api_type)
if runtime:
tibber_connection = runtime.tibber
await tibber_connection.rt_disconnect()
hass.data[DOMAIN].pop(api_type, None)
return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,36 +2,118 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber.data_api import TibberDataAPI
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2FlowHandler,
async_get_config_entry_implementation,
async_get_implementations,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import DOMAIN
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DATA_API_DEFAULT_SCOPES,
DOMAIN,
)
TYPE_SELECTOR = vol.Schema(
{
vol.Required(CONF_API_TYPE, default=API_TYPE_GRAPHQL): SelectSelector(
SelectSelectorConfig(
options=[API_TYPE_GRAPHQL, API_TYPE_DATA_API],
translation_key="api_type",
)
)
}
)
GRAPHQL_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
DATA_API_DOC_URL = "https://data-api.tibber.com/docs/auth/"
APPLICATION_CREDENTIALS_DOC_URL = (
"https://www.home-assistant.io/integrations/application_credentials/"
)
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._api_type: str | None = None
self._data_api_home_ids: list[str] = []
self._data_api_user_sub: str | None = None
self._reauth_confirmed: bool = False
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict:
"""Extra data appended to the authorize URL."""
if self._api_type != API_TYPE_DATA_API:
return super().extra_authorize_data
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match()
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=TYPE_SELECTOR,
description_placeholders={"url": DATA_API_DOC_URL},
)
self._api_type = user_input[CONF_API_TYPE]
if self._api_type == API_TYPE_GRAPHQL:
return await self.async_step_graphql()
return await self.async_step_data_api()
async def async_step_graphql(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle GraphQL token based configuration."""
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.entry_id == self.context.get("entry_id"):
continue
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_GRAPHQL:
return self.async_abort(reason="already_configured")
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
@@ -58,24 +140,146 @@ class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
step_id="graphql",
data_schema=GRAPHQL_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={
CONF_API_TYPE: API_TYPE_GRAPHQL,
CONF_ACCESS_TOKEN: access_token,
},
title=tibber_connection.name,
)
self._abort_if_unique_id_configured()
data = {
CONF_API_TYPE: API_TYPE_GRAPHQL,
CONF_ACCESS_TOKEN: access_token,
}
return self.async_create_entry(
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token},
data=data,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
step_id="graphql",
data_schema=GRAPHQL_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
)
async def async_step_data_api(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the Data API OAuth configuration."""
implementations = await async_get_implementations(self.hass, self.DOMAIN)
if not implementations:
return self.async_abort(
reason="missing_credentials",
description_placeholders={
"application_credentials_url": APPLICATION_CREDENTIALS_DOC_URL,
"data_api_url": DATA_API_DOC_URL,
},
)
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.entry_id == self.context.get("entry_id"):
continue
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
return self.async_abort(reason="already_configured")
return await self.async_step_pick_implementation(user_input)
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
assert self._api_type == API_TYPE_DATA_API
token: dict[str, Any] = data["token"]
client = TibberDataAPI(
token[CONF_ACCESS_TOKEN],
websession=async_get_clientsession(self.hass),
)
try:
userinfo = await client.get_userinfo()
except (
tibber.InvalidLoginError,
tibber.FatalHttpExceptionError,
) as err:
self.logger.error("Authentication failed against Data API: %s", err)
return self.async_abort(reason="oauth_invalid_token")
except (aiohttp.ClientError, TimeoutError) as err:
self.logger.error("Error retrieving homes via Data API: %s", err)
return self.async_abort(reason="cannot_connect")
unique_id = userinfo["email"]
title = userinfo["email"]
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"email": reauth_entry.unique_id or ""},
)
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_API_TYPE: API_TYPE_DATA_API,
"auth_implementation": data["auth_implementation"],
CONF_TOKEN: token,
},
title=title,
)
self._abort_if_unique_id_configured()
entry_data: dict[str, Any] = {
CONF_API_TYPE: API_TYPE_DATA_API,
"auth_implementation": data["auth_implementation"],
CONF_TOKEN: token,
}
return self.async_create_entry(
title=title,
data=entry_data,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
api_type = entry_data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
self._api_type = api_type
if api_type == API_TYPE_DATA_API:
self.flow_impl = await async_get_config_entry_implementation(
self.hass, self._get_reauth_entry()
)
return await self.async_step_auth()
self.context["title_placeholders"] = {"name": self._get_reauth_entry().title}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the reauth dialog for GraphQL entries."""
if user_input is None and not self._reauth_confirmed:
self._reauth_confirmed = True
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_graphql()

View File

@@ -3,3 +3,19 @@
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
CONF_API_TYPE = "api_type"
API_TYPE_GRAPHQL = "graphql"
API_TYPE_DATA_API = "data_api"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from typing import Any, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -22,6 +24,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -187,3 +190,48 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
runtime_data: Any,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = runtime_data
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return cast(
TibberDataAPI,
await self._runtime_data.async_get_client(self.hass),
)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
self.data = await client.get_all_devices()
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
return devices

View File

@@ -4,29 +4,80 @@ from __future__ import annotations
from typing import Any
import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN
from .const import API_TYPE_DATA_API, API_TYPE_GRAPHQL, CONF_API_TYPE, DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
domain_data = hass.data.get(DOMAIN, {})
if api_type == API_TYPE_GRAPHQL:
tibber_connection: tibber.Tibber = domain_data[API_TYPE_GRAPHQL].tibber
return {
"api_type": API_TYPE_GRAPHQL,
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
"has_active_subscription": home.has_active_subscription,
"has_real_time_consumption": home.has_real_time_consumption,
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
],
}
runtime = domain_data.get(API_TYPE_DATA_API)
if runtime is None:
return {
"api_type": API_TYPE_DATA_API,
"devices": [],
}
devices: dict[str, Any] = {}
error: str | None = None
try:
devices = await (await runtime.async_get_client(hass)).get_all_devices()
except ConfigEntryAuthFailed:
devices = {}
error = "Authentication failed"
except TimeoutError:
devices = {}
error = "Timeout error"
except aiohttp.ClientError:
devices = {}
error = "Client error"
except tibber.InvalidLoginError:
devices = {}
error = "Invalid login"
except tibber.RetryableHttpExceptionError as err:
devices = {}
error = f"Retryable HTTP error ({err.status})"
except tibber.FatalHttpExceptionError as err:
devices = {}
error = f"Fatal HTTP error ({err.status})"
return {
"homes": [
"api_type": API_TYPE_DATA_API,
"error": error,
"devices": [
{
"last_data_timestamp": home.last_data_timestamp,
"has_active_subscription": home.has_active_subscription,
"has_real_time_consumption": home.has_real_time_consumption,
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for home in tibber_connection.get_homes(only_active=False)
]
for device in devices.values()
],
}

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["recorder"],
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"]
"requirements": ["pyTibber==0.33.1"]
}

View File

@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import API_TYPE_GRAPHQL, DOMAIN
async def async_setup_entry(
@@ -39,7 +39,7 @@ class TibberNotificationEntity(NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN]
tibber_connection: Tibber = self.hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,7 +10,8 @@ from random import randrange
from typing import Any
import aiohttp
import tibber
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,6 +28,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -41,8 +43,14 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DOMAIN,
MANUFACTURER,
)
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,6 +268,58 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="connector.status",
translation_key="connector_status",
device_class=SensorDeviceClass.ENUM,
options=["connected", "disconnected", "unknown"],
),
SensorEntityDescription(
key="charging.status",
translation_key="charging_status",
device_class=SensorDeviceClass.ENUM,
options=["charging", "idle", "unknown"],
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -267,7 +327,11 @@ async def async_setup_entry(
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
await _async_setup_data_api_sensors(hass, entry, async_add_entities)
return
tibber_connection = hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +344,11 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -328,14 +396,94 @@ async def async_setup_entry(
async_add_entities(entities, True)
async def _async_setup_data_api_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
domain_data = hass.data.get(DOMAIN, {})
runtime = domain_data[API_TYPE_DATA_API]
coordinator = TibberDataAPICoordinator(hass, entry, runtime)
await coordinator.async_config_entry_first_refresh()
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.error("Sensor %s not found", sensor)
continue
entities.append(
TibberDataAPISensor(
coordinator, device, description, sensor.description
)
)
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
name: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_name = name
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(
self,
) -> StateType:
"""Return the value reported by the device."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return None
for sensor in device.sensors:
if sensor.id == self.entity_description.key:
return sensor.value
return None
@property
def available(self) -> bool:
"""Return whether the sensor is available."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return False
return self.native_value is not None
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
_attr_has_entity_name = True
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -366,7 +514,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None:
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -443,7 +591,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +618,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -532,7 +680,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -618,7 +766,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -18,7 +18,7 @@ from homeassistant.core import (
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .const import API_TYPE_GRAPHQL, DOMAIN
PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start"
@@ -33,7 +33,15 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
domain_data = call.hass.data.get(DOMAIN, {})
runtime = domain_data.get(API_TYPE_GRAPHQL)
if runtime is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="graphql_required",
)
tibber_connection = runtime.tibber
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -1,7 +1,13 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "Add Tibber Data API application credentials under application credentials before continuing. See {application_credentials_url} for guidance and {data_api_url} for API documentation.",
"oauth_invalid_token": "[%key:common::config_flow::abort::oauth2_error%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {email}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,11 +15,21 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"user": {
"graphql": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"description": "Enter your access token from {url}"
},
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_type": "API type"
},
"description": "Select which Tibber API you want to configure. See {url} for documentation."
}
}
},
@@ -40,6 +56,37 @@
"average_power": {
"name": "Average power"
},
"battery_battery_power": {
"name": "Battery power"
},
"battery_battery_state_of_charge": {
"name": "Battery state of charge"
},
"battery_stored_energy": {
"name": "Stored energy"
},
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"charging_status": {
"name": "Charging status",
"state": {
"charging": "Charging",
"idle": "Idle",
"unknown": "Unknown"
}
},
"connector_status": {
"name": "Connector status",
"state": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown"
}
},
"current_l1": {
"name": "Current L1"
},
@@ -55,6 +102,30 @@
"estimated_hour_consumption": {
"name": "Estimated consumption current hour"
},
"ev_charger_charge_current": {
"name": "Charge current"
},
"ev_charger_charging_state": {
"name": "Charging state"
},
"ev_charger_power": {
"name": "Charging power"
},
"ev_charger_session_energy": {
"name": "Session energy"
},
"ev_charger_total_energy": {
"name": "Total energy"
},
"heat_pump_measured_temperature": {
"name": "Measured temperature"
},
"heat_pump_operation_mode": {
"name": "Operation mode"
},
"heat_pump_target_temperature": {
"name": "Target temperature"
},
"last_meter_consumption": {
"name": "Last meter consumption"
},
@@ -88,9 +159,33 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Remaining range"
},
"signal_strength": {
"name": "Signal strength"
},
"solar_power": {
"name": "Solar power"
},
"solar_power_production": {
"name": "Power production"
},
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"storage_target_state_of_charge": {
"name": "Storage target state of charge"
},
"thermostat_measured_temperature": {
"name": "Measured temperature"
},
"thermostat_operation_mode": {
"name": "Operation mode"
},
"thermostat_target_temperature": {
"name": "Target temperature"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -103,13 +198,27 @@
}
},
"exceptions": {
"graphql_required": {
"message": "Configure the Tibber GraphQL API before calling this service."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}
},
"selector": {
"api_type": {
"options": {
"data_api": "Data API (OAuth2)",
"graphql": "GraphQL API (access token)"
}
}
},
"services": {
"get_prices": {
"description": "Fetches hourly energy prices including price level.",

View File

@@ -22,6 +22,7 @@ ATTR_FOLLOW_SINCE = "following_since"
ATTR_FOLLOWING = "followers"
ATTR_VIEWERS = "viewers"
ATTR_STARTED_AT = "started_at"
ATTR_CHANNEL_PICTURE = "channel_picture"
STATE_OFFLINE = "offline"
STATE_STREAMING = "streaming"
@@ -82,6 +83,7 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity):
ATTR_STARTED_AT: channel.started_at,
ATTR_VIEWERS: channel.viewers,
ATTR_SUBSCRIPTION: False,
ATTR_CHANNEL_PICTURE: channel.picture,
}
if channel.subscribed is not None:
resp[ATTR_SUBSCRIPTION] = channel.subscribed

View File

@@ -14,7 +14,6 @@ from homeassistant.core import (
ServiceResponse,
SupportsResponse,
)
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.location import find_coordinates
from homeassistant.helpers.selector import (
BooleanSelector,
@@ -47,6 +46,7 @@ from .const import (
VEHICLE_TYPES,
)
from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times
from .httpx_client import create_httpx_client
PLATFORMS = [Platform.SENSOR]
@@ -106,7 +106,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}):
hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1)
httpx_client = get_async_client(hass)
httpx_client = await create_httpx_client(hass)
client = WazeRouteCalculator(
region=config_entry.data[CONF_REGION].upper(), client=httpx_client
)
@@ -119,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse:
httpx_client = get_async_client(hass)
httpx_client = await create_httpx_client(hass)
client = WazeRouteCalculator(
region=service.data[CONF_REGION].upper(), client=httpx_client
)

View File

@@ -0,0 +1,26 @@
"""Special httpx client for Waze Travel Time integration."""
import httpx
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
DATA_HTTPX_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client")
def create_transport() -> httpx.AsyncHTTPTransport:
"""Create a httpx transport which enforces the use of IPv4."""
return httpx.AsyncHTTPTransport(local_address="0.0.0.0")
async def create_httpx_client(hass: HomeAssistant) -> httpx.AsyncClient:
"""Create a httpx client which enforces the use of IPv4."""
if (client := hass.data[DOMAIN].get(DATA_HTTPX_ASYNC_CLIENT)) is None:
transport = await hass.async_add_executor_job(create_transport)
client = hass.data[DOMAIN][DATA_HTTPX_ASYNC_CLIENT] = create_async_httpx_client(
hass, transport=transport
)
return client

View File

@@ -99,10 +99,13 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
state = self._device.state
color_modes = self.supported_color_modes
assert color_modes is not None
if (brightness := state.get_brightness()) is not None:
self._attr_brightness = max(0, min(255, brightness))
color_modes = self.supported_color_modes
assert color_modes is not None
if ColorMode.COLOR_TEMP in color_modes and (
color_temp := state.get_colortemp()
):
@@ -111,12 +114,19 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
elif (
ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None
):
self._attr_rgbww_color = rgbww
self._attr_color_mode = ColorMode.RGBWW
self._attr_rgbww_color = rgbww
elif ColorMode.RGBW in color_modes and (rgbw := state.get_rgbw()) is not None:
self._attr_rgbw_color = rgbw
self._attr_color_mode = ColorMode.RGBW
self._attr_effect = state.get_scene()
self._attr_rgbw_color = rgbw
self._attr_effect = effect = state.get_scene()
if effect is not None:
if brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
else:
self._attr_color_mode = ColorMode.ONOFF
super()._async_update_attrs()
async def async_turn_on(self, **kwargs: Any) -> None:

View File

@@ -28,6 +28,7 @@ from homeassistant.components.hassio import (
from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_USB,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
@@ -1516,6 +1517,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="not_hassio")
if discovery_info.zwave_home_id:
existing_entry: ConfigEntry | None = None
if (
(
current_config_entries := self._async_current_entries(
@@ -1533,26 +1535,30 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
None,
)
)
# Only update existing entries that are configured via sockets
and existing_entry.data.get(CONF_SOCKET_PATH)
# And use the add-on
and existing_entry.data.get(CONF_USE_ADDON)
):
manager = get_addon_manager(self.hass)
await self._async_set_addon_config(
{CONF_ADDON_SOCKET: discovery_info.socket_path}
)
if self.restart_addon:
await manager.async_stop_addon()
self.hass.config_entries.async_update_entry(
existing_entry,
data={
**existing_entry.data,
CONF_SOCKET_PATH: discovery_info.socket_path,
},
)
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
return self.async_abort(reason="already_configured")
# We can't migrate entries that are not using the add-on
if not existing_entry.data.get(CONF_USE_ADDON):
return self.async_abort(reason="already_configured")
# Only update config automatically if using socket
if existing_entry.data.get(CONF_SOCKET_PATH):
manager = get_addon_manager(self.hass)
await self._async_set_addon_config(
{CONF_ADDON_SOCKET: discovery_info.socket_path}
)
if self.restart_addon:
await manager.async_stop_addon()
self.hass.config_entries.async_update_entry(
existing_entry,
data={
**existing_entry.data,
CONF_SOCKET_PATH: discovery_info.socket_path,
},
)
self.hass.config_entries.async_schedule_reload(
existing_entry.entry_id
)
return self.async_abort(reason="already_configured")
# We are not aborting if home ID configured here, we just want to make sure that it's set
# We will update a USB based config entry automatically in `async_step_finish_addon_setup_user`

View File

@@ -37,6 +37,7 @@ APPLICATION_CREDENTIALS = [
"smartthings",
"spotify",
"tesla_fleet",
"tibber",
"twitch",
"volvo",
"weheat",

View File

@@ -157,6 +157,7 @@ FLOWS = {
"droplet",
"dsmr",
"dsmr_reader",
"duckdns",
"duke_energy",
"dunehd",
"duotecno",

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