Compare commits

...

164 Commits

Author SHA1 Message Date
Erik Montnemery
c0760f26bc Merge branch 'dev' into add_gate_triggers 2026-03-09 20:34:51 +01:00
Erik Montnemery
c037dad093 Add humidity triggers (#165197) 2026-03-09 20:34:26 +01:00
Erik
0ebe5434f5 Add gate triggers 2026-03-09 20:32:33 +01:00
Erik Montnemery
ce11e66e1f Add cover triggers (#165188)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-09 19:37:36 +01:00
David Bishop
f38ca7b04a Add unique_id to Whisker (Litter-Robot) config entries (#164766)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-09 19:35:34 +01:00
Tor André Roland
01200ef0a8 Optimizations to Adax local device control (#162109)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-09 19:29:43 +01:00
mettolen
c5e0c78cbc Minor Saunum integration improvements (#164705) 2026-03-09 19:22:27 +01:00
g4bri3lDev
7681caa936 Add diagnostics to OpenDisplay integration (#165222) 2026-03-09 19:05:52 +01:00
Bram Kragten
230a2ff045 Add reorder support to area selector (#165211) 2026-03-09 17:40:34 +01:00
A. Gideonse
9d828502a3 Fix code owner for indevolt integration (#165214) 2026-03-09 17:40:00 +01:00
Samuel Xiao
28088a7e1a Switchbot Cloud: Compatible with new device types (#165191) 2026-03-09 17:12:39 +01:00
epenet
9e8171fb77 Improve test coverage in Tuya light (#164954) 2026-03-09 17:11:26 +01:00
John O'Nolan
1660d3b28a Add stale device removal to Ghost integration (#165134) 2026-03-09 17:10:13 +01:00
Josef Zweck
2ef81a54a5 Allow backups to report the upload progress (#163608)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-09 17:12:49 +02:00
Samuel Xiao
ce6154839e Switchbot Cloud: Fixed light mode settings error (#164723) 2026-03-09 15:50:02 +01:00
Erik Montnemery
a25300b8e1 Fix import in cover (#165199) 2026-03-09 15:27:12 +01:00
Leon Grave
6fa8e71b21 Add freshr integration, based on pyfreshr (#164538)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-09 15:26:03 +01:00
tronikos
c983978a10 Remove type: ignore in Android TV Remote (#165126) 2026-03-09 14:42:51 +01:00
Joost Lekkerkerker
68b8b6b675 Add fixture for Air Purifier to SmartThings (#165187) 2026-03-09 14:21:34 +01:00
Martin Hjelmare
ee4d313b10 Fix update tests for Python 3.14.3 (#165196) 2026-03-09 14:21:18 +01:00
Erik Montnemery
5e665093c9 Revert "Add number.changed trigger" (#165193) 2026-03-09 13:55:08 +01:00
A. Gideonse
9a5f509ab9 Fix missing Gen-2 sensor for the Indevolt integration (#165133) 2026-03-09 13:49:54 +01:00
Erik Montnemery
8d0cd5edaa Remove some climate and humidifier triggers (#165192) 2026-03-09 13:37:31 +01:00
epenet
71726272f5 Speed up SmartThings tests (#165184)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 13:25:14 +01:00
epenet
9c6c27ab56 Avoid duplicate id/label in smartthings device fixtures (#165190) 2026-03-09 12:40:11 +01:00
Joost Lekkerkerker
db20cf8161 Rename SmartThings devices to maintain uniqueness (#165189) 2026-03-09 12:16:07 +01:00
John O'Nolan
59b6270157 Add reconfigure flow to Ghost integration (#165131) 2026-03-09 11:57:40 +01:00
epenet
a65ba01bbe Mark climate type hints as mandatory (#164982)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-09 11:50:42 +01:00
Erik Montnemery
a5d0350560 Add garage_door triggers (#165144)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 11:42:09 +01:00
Shai Ungar
368993556f Bump pyseventeentrack to 1.1.2 (#165089)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:38:48 +01:00
Daniel Shneyder
23ea17eaef Bump kaiterra-async-client to 1.1.0 (#165166) 2026-03-09 09:59:55 +01:00
g4bri3lDev
6ace93e45b Bump py-opendisplay to 5.5.0 (#165138)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 09:29:57 +01:00
epenet
237a0ae03f Improve type hints in ecobee climate (#165178) 2026-03-09 09:16:43 +01:00
epenet
6067be6f49 Improve type hints in lightwave climate (#165179) 2026-03-09 09:16:29 +01:00
J. Nick Koston
a35c3d5de5 Bump yalexs-ble to 3.3.0 (#165168) 2026-03-08 16:39:30 -10:00
J. Nick Koston
e9c3634cb6 Bump habluetooth to 5.9.1 and bleak-retry-connector to 4.6.0 (#165022) 2026-03-08 16:16:53 -10:00
J. Nick Koston
2ba4544180 Bump yalexs-ble to 3.2.8 (#165018) 2026-03-09 03:07:49 +01:00
Artur Pragacz
5235ce7ae4 Lower ssdp discovery timeout log severity in Onkyo (#165156) 2026-03-09 02:19:42 +01:00
Oscar
56b601e577 Add basic auth support to remote_calendar (#158075) 2026-03-08 16:52:58 -07:00
Justin Boyd
f01a0586cb Bump airtouch5py to 0.4.0 (#161640)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-03-08 21:47:06 +01:00
Erwin Douna
ca641a097b Fix forced VERIFY_SSL in Portainer (#165079) 2026-03-08 13:19:45 +01:00
Åke Strandberg
df2f9d9ef8 Add missing code for Miele dryer (#165122) 2026-03-08 13:18:54 +01:00
Bouwe Westerdijk
501301f4e0 Bump plugwise to v1.11.3 (#165053) 2026-03-08 13:15:44 +01:00
Joakim Plate
89231a1a29 Update pychromecast to 14.0.10 (#165069) 2026-03-08 13:14:34 +01:00
John O'Nolan
fe11a6d38f Add diagnostics to Ghost integration (#165130) 2026-03-08 13:03:57 +01:00
Artur Pragacz
3154c3c962 Make restore state resilient to extra_restore_state_data errors (#165086) 2026-03-08 10:39:53 +01:00
mettolen
5031323dea Add description strings to Huum integration (#165094) 2026-03-08 10:24:15 +01:00
Henning Kerstan
017a9e6938 Bump enocean-async to 0.4.2 (#165084) 2026-03-08 09:02:51 +00:00
tronikos
9e974ab30e Add diagnostics in Opower (#165113) 2026-03-08 09:14:15 +01:00
Norbert Rittel
30c0d6792a Make spelling of "auto-empty dock" consistent in roborock (#165117) 2026-03-08 09:12:56 +01:00
Erwin Douna
9ffb9aa824 Bump pyportainer to 1.0.33 (#165080) 2026-03-08 08:33:33 +01:00
A. Gideonse
9ad71711da Add diagnostics to Indevolt integration (#165096) 2026-03-08 08:32:18 +01:00
Steve Easley
ef83165159 Bump jvc_projector dependency to 2.0.2 (#165099) 2026-03-08 08:29:53 +01:00
Jordan Harvey
f0108c1175 Bump pyanglianwater to 3.1.1 (#165097) 2026-03-08 08:28:06 +01:00
Richard Kroegel
802aa991a9 Remove broken BMW & Mini integrations (#165075) 2026-03-08 00:00:03 +00:00
Sab44
f055c6c7fd Add quality scale exemptions for discovery in Libre Hardware Monitor (#165085) 2026-03-07 23:29:07 +01:00
Joel Hawksley
2a8b045f43 Update weatherkit to fetch hourly data for 7 days (#164494) 2026-03-07 19:08:13 +00:00
Erik Montnemery
281f439bc9 Add trigger door.closed (#165057) 2026-03-07 13:18:46 +00:00
Erik Montnemery
71b420b433 Add trigger door.opened (#164728) 2026-03-07 12:59:09 +01:00
J. Nick Koston
2f02d0f0dc Bump bleak-esphome to 3.7.1 (#165025) 2026-03-07 11:27:59 +00:00
Allen Porter
37cb3cbd50 Bump pyrainbird to 6.1.1 (#165030) 2026-03-07 11:27:28 +00:00
AlCalzone
beec21c4a9 Fix cover state updates for legacy Multilevel Switch based Z-Wave covers (#165003) 2026-03-07 12:16:30 +01:00
Pete Sage
642f603ea2 Add binary_sensors for Rehlko load shedding (#164984) 2026-03-07 11:59:44 +01:00
Abílio Costa
a3d8d76678 Simplify AGENTS.md (#164894) 2026-03-07 06:27:44 +01:00
J. Nick Koston
c25feaa62b Bump aioesphomeapi to 44.3.1 (#165023) 2026-03-06 19:02:18 -10:00
Glenn Waters
50bde6fccd Hunter Douglas Powerview: Fix missing class in hierarchy. (#164264)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-06 21:16:38 +01:00
Karl Beecken
1b7398c271 Bump teltasync to 0.2.0 (#164995) 2026-03-06 21:16:19 +01:00
Sid
7e4b8e802e Add support for the reeflexUV+e to eheimdigital (#163656) 2026-03-06 20:28:39 +01:00
Joost Lekkerkerker
4bcea27151 Bump spotifyaio to 2.0.2 (#164114)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-06 20:28:04 +01:00
konsulten
ffca43027f Add reconfigure flow for systemnexa2 (#164361) 2026-03-06 20:23:17 +01:00
Joshua Leaper
01e94ca5b2 Update ness_alarm scan interval to 5 secs (#164835) 2026-03-06 20:12:35 +01:00
Petro31
b8ea6b4162 Update template light test framework (#164688) 2026-03-06 20:12:10 +01:00
epenet
1471cb93bc Move smart_meter_texas coordinator to separate module (#164926)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:11:38 +01:00
Erwin Douna
2f7ac2b439 Migrate Smartthings OAuth exceptions (#164939)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-06 20:10:41 +01:00
epenet
0accb403be Move WattTime coordinator to separate module (#164726)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:10:14 +01:00
epenet
f49a323faf Move wolflink coordinator to separate module (#164929)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-06 20:08:29 +01:00
TimL
21d303dbbc Fix button entity creation for devices with more than two radios (#164699) 2026-03-06 20:07:56 +01:00
Antonio Mello
c080a460a2 Fix IntesisHome outdoor_temp not reported when value is 0.0 (#164703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:07:11 +01:00
epenet
75d675f299 Move AirVisual coordinator to separate module (#164738)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:06:18 +01:00
epenet
a7e7d01b7a Move launch_library coordinator to separate module (#164747)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:05:42 +01:00
epenet
8a0569e279 Move AirVisual Pro coordinator to separate module (#164742)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-06 20:05:30 +01:00
epenet
e8279bd20f Move LED BLE coordinator to separate module (#164749)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:04:51 +01:00
epenet
852dbf8986 Move peco coordinator to separate module (#164851)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 20:04:34 +01:00
hanwg
6f0eb1d07a Upgrade IQS to gold for Telegram bot (#164911) 2026-03-06 20:04:01 +01:00
epenet
6f68d91593 Move DataUpdateCoordinator to coordinator module in tesla_wall_connector (#164937)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:01:16 +01:00
epenet
ffc17b6e91 Move whois coordinator to separate module (#164936)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-06 20:00:18 +01:00
epenet
0d04d79844 Move DataUpdateCoordinator to separate module in reolink (#164914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:59:56 +01:00
epenet
f57884cb95 Move kraken API wrapper class to coordinator module (#164942)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:54:20 +01:00
Manu
3a83fe5c72 Change setpoint step size in IronOS integration (#164979) 2026-03-06 19:38:26 +01:00
Willem-Jan van Rootselaar
973feb71c1 Bump python-bsblan to 5.1.2 (#164963) 2026-03-06 19:37:55 +01:00
epenet
ecee23fc7a Move pi_hole coordinator to separate module (#164869)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-06 19:36:52 +01:00
epenet
442d2282dc Improve type hints in maxcube climate (#164978) 2026-03-06 18:10:51 +01:00
Robert Resch
8853d3e17d Add lawn mower started_returning trigger (#164834) 2026-03-06 18:08:28 +01:00
epenet
6d1e387911 Improve type hints in airtouch4 climate (#164977) 2026-03-06 18:05:27 +01:00
epenet
13fe135e7f Improve type hints in nexia climate (#164976) 2026-03-06 18:04:56 +01:00
epenet
618687ea05 Improve type hints in nuheat climate (#164975) 2026-03-06 18:04:24 +01:00
epenet
8b545a6e76 Improve type hints in oem climate (#164974) 2026-03-06 18:04:07 +01:00
epenet
42fa13200d Improve type hints in proliphix climate (#164972) 2026-03-06 18:03:39 +01:00
epenet
d56e944a86 Improve type hints in schluter climate (#164970) 2026-03-06 18:03:17 +01:00
epenet
fb357390ce Remove disabled Tfiac integration (#164966) 2026-03-06 18:00:42 +01:00
Shay Levy
702450e209 Bump aioswitcher to 6.1.1 (#164981) 2026-03-06 17:54:38 +01:00
g4bri3lDev
bbe45e0759 Add OpenDisplay integration (#164048)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-06 16:23:09 +01:00
epenet
92902c7aa1 Improve type hints in smarttub climate (#164968) 2026-03-06 16:07:41 +01:00
epenet
5d92dd7760 Use shorthand attributes in zhong_hong climate (#164964) 2026-03-06 16:00:14 +01:00
Joost Lekkerkerker
0ab62dabde Create Chess.com integration (#164960) 2026-03-06 15:55:59 +01:00
Sean O'Keeffe
fc68828c78 more programs for Miele steam ovens (#164768)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-06 15:15:43 +01:00
Sab44
7644036592 Add diagnostics to Libre Hardware Monitor (#164958)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-06 15:15:18 +01:00
epenet
f19068f7de Mark device_info type hint as mandatory (#164951) 2026-03-06 15:15:05 +01:00
Robin Lintermann
13d2211755 Add sensor entity for total swing time (#164334) 2026-03-06 15:10:06 +01:00
epenet
87e63591d1 Use shorthand attributes in heatmiser climate (#164957) 2026-03-06 15:00:51 +01:00
epenet
fc02bbcdd0 Improve type hints in coolmaster climate (#164956) 2026-03-06 15:00:13 +01:00
Simone Chemelli
388d619604 Bump aiovodafone to 3.1.3 (#164955) 2026-03-06 14:59:51 +01:00
Daniel Hjelseth Høyer
3777acff95 Fix energy unit in Homevolt (#164959)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-03-06 14:58:44 +01:00
Jamie Magee
e0fd6784cf Test aladdin_connect stale device cleanup (#164119) 2026-03-06 13:03:09 +01:00
epenet
305463d882 Move DataUpdateCoordinator to coordinator module in nsw_fuel_station (#164940)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:25:07 +01:00
Erwin Douna
de16edc55b Replace assert in Proxmox coordinator (#164892) 2026-03-06 11:16:14 +01:00
Erwin Douna
bd6438937b Adjust read-only parallel updates for Portainer (#164890) 2026-03-06 11:14:58 +01:00
Erwin Douna
45e453791e Update Proxmox code owners (#164941) 2026-03-06 11:11:06 +01:00
epenet
152137a3a2 Move DataUpdateCoordinator to separate module in simplisafe (#164917)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:10:31 +01:00
epenet
e059c51b1d Move wiz coordinator to separate module (#164931)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:21:07 +01:00
epenet
9ef66a3a90 Move supla coordinator to separate module (#164928)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:20:42 +01:00
Petro31
494f8c32d5 Fix 'this' variable in template options flow (#164866) 2026-03-06 09:39:42 +01:00
dependabot[bot]
51f90a328b Bump actions/attest-build-provenance from 3.2.0 to 4.1.0 (#164909)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 09:38:33 +01:00
epenet
b7bdb7b32a Move DataUpdateCoordinator to separate module in subaru (#164918)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:09:03 +01:00
epenet
76c8bae098 Use typed coordinator in powerwall (#164887)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 08:35:09 +01:00
Erwin Douna
59a75e74fe Bump proxmoxer 2.3.0 (#164884) 2026-03-06 08:34:45 +01:00
Christopher Fenner
a4af1ce5f8 Translate device name in Season integration (#164882) 2026-03-06 08:33:20 +01:00
Erwin Douna
30ea0b4923 Proxmoxve add parallel updates (#164889) 2026-03-06 08:32:36 +01:00
Erwin Douna
fb889dd524 Optimize init proxmox (#164891) 2026-03-06 08:32:18 +01:00
epenet
31055c5cde Move DataUpdateCoordinator to separate module in recollect_waste (#164913)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:31:15 +01:00
epenet
a264e5949f Move DataUpdateCoordinator to separate module in senz (#164916)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:30:29 +01:00
Colin
84260ac3f7 Use shared aiohttp session in openevse (#164552) 2026-03-06 07:49:53 +01:00
epenet
f50a35877d Move RDW DataUpdateCoordinator to separate module (#164910)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:47:08 +01:00
Luke Lashley
6bc94a318a Pass in Base Url during Roborock reauth (#164903) 2026-03-05 20:24:59 -08:00
Blake Messer
b0904917ca Fix Rain Bird controllers updated by Rain Bird 2.x (#163915) 2026-03-05 19:37:15 -08:00
Michael
536cfc4c67 Add number.changed trigger (#163984) 2026-03-05 21:36:39 +01:00
Erwin Douna
27b647fa36 Add backoff/max retries in Portainer API (#164805)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-05 21:26:22 +01:00
Michael
16fb2dfa91 Add domain driven triggers to schedule helper (#159325) 2026-03-05 21:26:05 +01:00
Josef Zweck
664b75e060 Bump onedrive-personal-sdk to 0.1.5 (#164880) 2026-03-05 20:19:19 +00:00
Erik Montnemery
1cd302eb17 Fix flaky bang_olufsen tests (#164868) 2026-03-05 21:18:10 +01:00
Dan Carroll
8da86796d2 Bump pyeconet to 0.2.2 (#164859) 2026-03-05 20:17:57 +00:00
Denis Shulyaka
33c0edc994 Add GPT-5.4 support to OpenAI conversation (#164883) 2026-03-05 20:16:53 +00:00
epenet
3e8833da54 Refactor Tuya wrappers to use generics (#164587) 2026-03-05 19:22:48 +01:00
Michael Hansen
3858d557b3 Add missing parameters from handle REST API (#164687)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-03-05 11:48:57 -06:00
Renat Sibgatulin
0923bed4b6 Add zeroconf support for air-Q (#164727)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 17:55:34 +01:00
Marc Mueller
9b8432eac3 Fix volvo test RuntimeWarning (#164845) 2026-03-05 17:51:12 +01:00
Tucker Kern
5232c05702 Ensure Snapcast client has a valid current group before accessing group attributes. (#164683) 2026-03-05 17:50:31 +01:00
Erik Montnemery
e5f77801a7 Unconditionally set up base platform integrations (#164863) 2026-03-05 17:30:34 +01:00
Erik Montnemery
bc138b3485 Fix incomplete device info in laundrify sensor (#164824)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-05 17:08:31 +01:00
Andrew Jackson
ae90c5fa92 Update Mastodon quality scale to gold (#164842) 2026-03-05 16:50:45 +01:00
Matthias Alphart
2fce45abe1 Fix KNX sensor default attributes for energy and volume DPTs (#164838)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 16:48:47 +01:00
karwosts
e4417f7b00 Add unique_id to demo water_heater (#164857) 2026-03-05 16:40:17 +01:00
Ariel Ebersberger
b57c7f8a95 Fix ffmpeg fixture (#164860) 2026-03-05 16:37:43 +01:00
Henning Kerstan
0618460d73 Replace enocean library (#164272)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 16:30:06 +01:00
epenet
92dd045772 Move Mullvad VPN coordinator to separate module (#164750)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 16:20:58 +01:00
Michael Hansen
fc723e1a42 Add missing features to Wyoming conversation agent (#164278) 2026-03-05 15:56:21 +01:00
Joshua Monta
5907356309 Add new influenza index sensor to Uhoo (#164710) 2026-03-05 15:37:22 +01:00
J. Diego Rodríguez Royo
1c221b4714 Bump aiohomeconnect to 0.30.0 (#164846)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 15:34:12 +01:00
Retha Runolfsson
05d57167d2 Add support for switchbot keypad vision (#160484)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-05 14:54:07 +01:00
epenet
69a98dd53e Move nuheat coordinator to separate module (#164833)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 14:16:55 +01:00
John O'Nolan
3c7dd93c7f Add reauthentication flow to Ghost integration (Silver) (#164847) 2026-03-05 14:16:03 +01:00
reneboer
1327712be4 Add sensor charging settings mode (#164455)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-05 13:24:23 +01:00
epenet
933e57ba6a Simplify Netgear entity initialisation (#164837) 2026-03-05 13:17:19 +01:00
John O'Nolan
77d54aadc6 Fix Ghost config flow using wrong field name for site UUID (#164836) 2026-03-05 12:46:59 +01:00
631 changed files with 22728 additions and 27575 deletions

View File

@@ -7,328 +7,20 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Code Review Guidelines
**When reviewing code, do NOT comment on:**
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use the newest features when possible:
- Pattern matching
- Type hints
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Walrus operator
### Strict Typing (Platinum)
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
- **Custom Config Entry Types**: When using runtime_data:
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
```
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
## Code Quality Standards
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
### Writing Style Guidelines
- **Tone**: Friendly and informative
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
- **Inclusivity**: Use objective, non-discriminatory language
- **Clarity**: Write for non-native English speakers
- **Formatting in Messages**:
- Use backticks for: file paths, filenames, variable names, field entries
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
- Avoid abbreviations when possible
### Documentation Standards
- **File Headers**: Short and concise
```python
"""Integration for Peblar EV chargers."""
```
- **Method/Function Docstrings**: Required for all
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
```
- **Comment Style**:
- Use clear, descriptive comments
- Explain the "why" not just the "what"
- Keep code block lines under 80 characters when possible
- Use progressive disclosure (simple explanation first, complex details later)
## Async Programming
- All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops
- Avoid awaiting in loops - use `gather` instead
- No blocking calls
- Group executor jobs when possible - switching between event loop and executor is expensive
### Blocking Operations
- **Use Executor**: For blocking I/O operations
```python
result = await hass.async_add_executor_job(blocking_function, args)
```
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
### Thread Safety
- **@callback Decorator**: For event loop safe functions
```python
@callback
def async_update_callback(self, event):
"""Safe to run in event loop."""
self.async_write_ha_state()
```
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
- **Registry Changes**: Must be done in event loop thread
### Error Handling
- **Exception Types**: Choose most specific exception available
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
- `HomeAssistantError`: Device communication failures
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
- `ConfigEntryAuthFailed`: Authentication problems
- `ConfigEntryError`: Permanent setup issues
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/catch
- **Avoid bare exceptions** except in specific cases:
- ❌ Generally not allowed: `except:` or `except Exception:`
- ✅ Allowed in config flows to ensure robustness
- ✅ Allowed in functions/methods that run in background tasks
- Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
except DeviceError:
_LOGGER.error("Failed to get data")
```
- Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
_LOGGER.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
```
- **Bare Exception Usage**:
```python
# ❌ Not allowed in regular code
try:
data = await device.get_data()
except Exception: # Too broad
_LOGGER.error("Failed")
# ✅ Allowed in config flow for robustness
async def async_step_user(self, user_input=None):
try:
await self._test_connection(user_input)
except Exception: # Allowed here
errors["base"] = "unknown"
# ✅ Allowed in background tasks
async def _background_refresh():
try:
await coordinator.async_refresh()
except Exception: # Allowed in task
_LOGGER.exception("Unexpected error in background task")
```
- **Setup Failure Patterns**:
```python
try:
await device.async_setup()
except (asyncio.TimeoutError, TimeoutException) as ex:
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
except AuthFailed as ex:
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
```
### Logging
- **Format Guidelines**:
- No periods at end of messages
- No integration names/domains (added automatically)
- No sensitive data (keys, tokens, passwords)
- Use debug level for non-user-facing messages
- **Use Lazy Logging**:
```python
_LOGGER.debug("This is a log message with %s", variable)
```
### Unavailability Logging
- **Log Once**: When device/service becomes unavailable (info level)
- **Log Recovery**: When device/service comes back online
- **Implementation Pattern**:
```python
_unavailable_logged: bool = False
if not self._unavailable_logged:
_LOGGER.info("The sensor is unavailable: %s", ex)
self._unavailable_logged = True
# On recovery:
if self._unavailable_logged:
_LOGGER.info("The sensor is back online")
self._unavailable_logged = False
```
## Development Commands
### Environment
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
- **Dev container**: No activation needed, the environment is pre-configured
.vscode/tasks.json contains useful commands used for development.
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
## Python Syntax Notes
### Testing
- **Quick test of changed files**: `pytest --timeout=10 --picked`
- **Update test snapshots**: Add `--snapshot-update` to pytest command
- ⚠️ Omit test results after using `--snapshot-update`
- Always run tests again without the flag to verify snapshots
- **Full test suite** (AVOID - very slow): `pytest ./tests`
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
### Dependencies & Requirements
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
- **Install all Python requirements**:
```bash
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
```
- **Install test requirements only**:
```bash
uv pip install -r requirements_test_all.txt -r requirements.txt
```
## Good practices
### Translations
- **Update translations after strings.json changes**:
```bash
python -m script.translations develop --all
```
### Project Validation
- **Run hassfest** (checks project structure and updates generated files):
```bash
python -m script.hassfest
```
## Common Anti-Patterns & Best Practices
### ❌ **Avoid These Patterns**
```python
# Blocking operations in event loop
data = requests.get(url) # ❌ Blocks event loop
time.sleep(5) # ❌ Blocks event loop
# Reusing BleakClient instances
self.client = BleakClient(address)
await self.client.connect()
# Later...
await self.client.connect() # ❌ Don't reuse
# Hardcoded strings in code
self._attr_name = "Temperature Sensor" # ❌ Not translatable
# Missing error handling
data = await self.api.get_data() # ❌ No exception handling
# Storing sensitive data in diagnostics
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
# Accessing hass.data directly in tests
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
# User-configurable polling intervals
# In config flow
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
# In coordinator
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
# User-configurable config entry names (non-helper integrations)
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
# Too much code in try block
try:
response = await client.get_data() # Can throw
# ❌ Data processing should be outside try block
temperature = response["temperature"] / 10
humidity = response["humidity"]
self._attr_native_value = temperature
except ClientError:
_LOGGER.error("Failed to fetch data")
# Bare exceptions in regular code
try:
value = await sensor.read_value()
except Exception: # ❌ Too broad - catch specific exceptions
_LOGGER.error("Failed to read sensor")
```
### ✅ **Use These Patterns Instead**
```python
# Async operations with executor
data = await hass.async_add_executor_job(requests.get, url)
await asyncio.sleep(5) # ✅ Non-blocking
# Fresh BleakClient instances
client = BleakClient(address) # ✅ New instance each time
await client.connect()
# Translatable entity names
_attr_translation_key = "temperature_sensor" # ✅ Translatable
# Proper error handling
try:
data = await self.api.get_data()
except ApiException as err:
raise UpdateFailed(f"API error: {err}") from err
# Redacted diagnostics data
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
# Test through proper integration setup and fixtures
@pytest.fixture
async def init_integration(hass, mock_config_entry, mock_api):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
# Integration-determined polling intervals (not user-configurable)
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
# ✅ Integration determines interval based on device capabilities, connection type, etc.
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
# Skills

View File

@@ -614,7 +614,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@@ -123,7 +123,6 @@ homeassistant.components.blueprint.*
homeassistant.components.bluesound.*
homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_adapters.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.bosch_alarm.*
homeassistant.components.braviatv.*
@@ -213,6 +212,7 @@ homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
homeassistant.components.freshr.*
homeassistant.components.fritz.*
homeassistant.components.fritzbox.*
homeassistant.components.fritzbox_callmonitor.*

318
AGENTS.md
View File

@@ -4,325 +4,17 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Code Review Guidelines
**When reviewing code, do NOT comment on:**
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use the newest features when possible:
- Pattern matching
- Type hints
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Walrus operator
### Strict Typing (Platinum)
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
- **Custom Config Entry Types**: When using runtime_data:
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
```
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
## Code Quality Standards
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
### Writing Style Guidelines
- **Tone**: Friendly and informative
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
- **Inclusivity**: Use objective, non-discriminatory language
- **Clarity**: Write for non-native English speakers
- **Formatting in Messages**:
- Use backticks for: file paths, filenames, variable names, field entries
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
- Avoid abbreviations when possible
### Documentation Standards
- **File Headers**: Short and concise
```python
"""Integration for Peblar EV chargers."""
```
- **Method/Function Docstrings**: Required for all
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
```
- **Comment Style**:
- Use clear, descriptive comments
- Explain the "why" not just the "what"
- Keep code block lines under 80 characters when possible
- Use progressive disclosure (simple explanation first, complex details later)
## Async Programming
- All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops
- Avoid awaiting in loops - use `gather` instead
- No blocking calls
- Group executor jobs when possible - switching between event loop and executor is expensive
### Blocking Operations
- **Use Executor**: For blocking I/O operations
```python
result = await hass.async_add_executor_job(blocking_function, args)
```
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
### Thread Safety
- **@callback Decorator**: For event loop safe functions
```python
@callback
def async_update_callback(self, event):
"""Safe to run in event loop."""
self.async_write_ha_state()
```
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
- **Registry Changes**: Must be done in event loop thread
### Error Handling
- **Exception Types**: Choose most specific exception available
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
- `HomeAssistantError`: Device communication failures
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
- `ConfigEntryAuthFailed`: Authentication problems
- `ConfigEntryError`: Permanent setup issues
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/catch
- **Avoid bare exceptions** except in specific cases:
- ❌ Generally not allowed: `except:` or `except Exception:`
- ✅ Allowed in config flows to ensure robustness
- ✅ Allowed in functions/methods that run in background tasks
- Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
except DeviceError:
_LOGGER.error("Failed to get data")
```
- Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
_LOGGER.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
```
- **Bare Exception Usage**:
```python
# ❌ Not allowed in regular code
try:
data = await device.get_data()
except Exception: # Too broad
_LOGGER.error("Failed")
# ✅ Allowed in config flow for robustness
async def async_step_user(self, user_input=None):
try:
await self._test_connection(user_input)
except Exception: # Allowed here
errors["base"] = "unknown"
# ✅ Allowed in background tasks
async def _background_refresh():
try:
await coordinator.async_refresh()
except Exception: # Allowed in task
_LOGGER.exception("Unexpected error in background task")
```
- **Setup Failure Patterns**:
```python
try:
await device.async_setup()
except (asyncio.TimeoutError, TimeoutException) as ex:
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
except AuthFailed as ex:
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
```
### Logging
- **Format Guidelines**:
- No periods at end of messages
- No integration names/domains (added automatically)
- No sensitive data (keys, tokens, passwords)
- Use debug level for non-user-facing messages
- **Use Lazy Logging**:
```python
_LOGGER.debug("This is a log message with %s", variable)
```
### Unavailability Logging
- **Log Once**: When device/service becomes unavailable (info level)
- **Log Recovery**: When device/service comes back online
- **Implementation Pattern**:
```python
_unavailable_logged: bool = False
if not self._unavailable_logged:
_LOGGER.info("The sensor is unavailable: %s", ex)
self._unavailable_logged = True
# On recovery:
if self._unavailable_logged:
_LOGGER.info("The sensor is back online")
self._unavailable_logged = False
```
## Development Commands
### Environment
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
- **Dev container**: No activation needed, the environment is pre-configured
.vscode/tasks.json contains useful commands used for development.
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
## Python Syntax Notes
### Testing
- **Quick test of changed files**: `pytest --timeout=10 --picked`
- **Update test snapshots**: Add `--snapshot-update` to pytest command
- ⚠️ Omit test results after using `--snapshot-update`
- Always run tests again without the flag to verify snapshots
- **Full test suite** (AVOID - very slow): `pytest ./tests`
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
### Dependencies & Requirements
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
- **Install all Python requirements**:
```bash
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
```
- **Install test requirements only**:
```bash
uv pip install -r requirements_test_all.txt -r requirements.txt
```
## Good practices
### Translations
- **Update translations after strings.json changes**:
```bash
python -m script.translations develop --all
```
### Project Validation
- **Run hassfest** (checks project structure and updates generated files):
```bash
python -m script.hassfest
```
## Common Anti-Patterns & Best Practices
### ❌ **Avoid These Patterns**
```python
# Blocking operations in event loop
data = requests.get(url) # ❌ Blocks event loop
time.sleep(5) # ❌ Blocks event loop
# Reusing BleakClient instances
self.client = BleakClient(address)
await self.client.connect()
# Later...
await self.client.connect() # ❌ Don't reuse
# Hardcoded strings in code
self._attr_name = "Temperature Sensor" # ❌ Not translatable
# Missing error handling
data = await self.api.get_data() # ❌ No exception handling
# Storing sensitive data in diagnostics
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
# Accessing hass.data directly in tests
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
# User-configurable polling intervals
# In config flow
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
# In coordinator
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
# User-configurable config entry names (non-helper integrations)
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
# Too much code in try block
try:
response = await client.get_data() # Can throw
# ❌ Data processing should be outside try block
temperature = response["temperature"] / 10
humidity = response["humidity"]
self._attr_native_value = temperature
except ClientError:
_LOGGER.error("Failed to fetch data")
# Bare exceptions in regular code
try:
value = await sensor.read_value()
except Exception: # ❌ Too broad - catch specific exceptions
_LOGGER.error("Failed to read sensor")
```
### ✅ **Use These Patterns Instead**
```python
# Async operations with executor
data = await hass.async_add_executor_job(requests.get, url)
await asyncio.sleep(5) # ✅ Non-blocking
# Fresh BleakClient instances
client = BleakClient(address) # ✅ New instance each time
await client.connect()
# Translatable entity names
_attr_translation_key = "temperature_sensor" # ✅ Translatable
# Proper error handling
try:
data = await self.api.get_data()
except ApiException as err:
raise UpdateFailed(f"API error: {err}") from err
# Redacted diagnostics data
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
# Test through proper integration setup and fixtures
@pytest.fixture
async def init_integration(hass, mock_config_entry, mock_api):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
# Integration-determined polling intervals (not user-configurable)
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
# ✅ Integration determines interval based on device capabilities, connection type, etc.
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

29
CODEOWNERS generated
View File

@@ -234,8 +234,6 @@ build.json @home-assistant/supervisor
/tests/components/bluetooth/ @bdraco
/homeassistant/components/bluetooth_adapters/ @bdraco
/tests/components/bluetooth_adapters/ @bdraco
/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
@@ -281,6 +279,8 @@ build.json @home-assistant/supervisor
/tests/components/cert_expiry/ @jjlawren
/homeassistant/components/chacon_dio/ @cnico
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -383,6 +383,8 @@ build.json @home-assistant/supervisor
/tests/components/dlna_dms/ @chishm
/homeassistant/components/dnsip/ @gjohansson-ST
/tests/components/dnsip/ @gjohansson-ST
/homeassistant/components/door/ @home-assistant/core
/tests/components/door/ @home-assistant/core
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
/homeassistant/components/dormakaba_dkey/ @emontnemery
@@ -549,6 +551,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
/tests/components/freshr/ @SierraNL
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
@@ -567,10 +571,14 @@ build.json @home-assistant/supervisor
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
/tests/components/garage_door/ @home-assistant/core
/homeassistant/components/garages_amsterdam/ @klaasnicolaas
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -737,6 +745,8 @@ build.json @home-assistant/supervisor
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/humidity/ @home-assistant/core
/tests/components/humidity/ @home-assistant/core
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555
@@ -786,8 +796,8 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/indevolt/ @xirtnl
/tests/components/indevolt/ @xirtnl
/homeassistant/components/indevolt/ @xirt
/tests/components/indevolt/ @xirt
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
@@ -1200,6 +1210,8 @@ build.json @home-assistant/supervisor
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w @firstof9
@@ -1305,8 +1317,8 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
@@ -1650,8 +1662,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
/tests/components/systemnexa2/ @konsulten @slangstrom
/homeassistant/components/systemnexa2/ @konsulten
/tests/components/systemnexa2/ @konsulten
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core
@@ -1691,7 +1703,6 @@ build.json @home-assistant/supervisor
/tests/components/tessie/ @Bre77
/homeassistant/components/text/ @home-assistant/core
/tests/components/text/ @home-assistant/core
/homeassistant/components/tfiac/ @fredrike @mellado
/homeassistant/components/thermobeacon/ @bdraco
/tests/components/thermobeacon/ @bdraco
/homeassistant/components/thermopro/ @bdraco @h3ss

View File

@@ -236,6 +236,15 @@ DEFAULT_INTEGRATIONS = {
"input_text",
"schedule",
"timer",
#
# Base platforms:
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

40
homeassistant/components/adax/climate.py Normal file → Executable file
View File

@@ -168,29 +168,57 @@ class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
if hvac_mode == HVACMode.HEAT:
temperature = self._attr_target_temperature or self._attr_min_temp
await self._adax_data_handler.set_target_temperature(temperature)
self._attr_target_temperature = temperature
self._attr_icon = "mdi:radiator"
elif hvac_mode == HVACMode.OFF:
await self._adax_data_handler.set_target_temperature(0)
self._attr_icon = "mdi:radiator-off"
else:
# Ignore unsupported HVAC modes to avoid desynchronizing entity state
# from the physical device.
return
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self._adax_data_handler.set_target_temperature(temperature)
if self._attr_hvac_mode == HVACMode.HEAT:
await self._adax_data_handler.set_target_temperature(temperature)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_target_temperature = temperature
self.async_write_ha_state()
def _update_hvac_attributes(self) -> None:
"""Update hvac mode and temperatures from coordinator data.
The coordinator reports a target temperature of 0 when the heater is
turned off. In that case, only the hvac mode and icon are updated and
the previous non-zero target temperature is preserved. When the
reported target temperature is non-zero, the stored target temperature
is updated to match the coordinator value.
"""
if data := self.coordinator.data:
self._attr_current_temperature = data["current_temperature"]
self._attr_available = self._attr_current_temperature is not None
if (target_temp := data["target_temperature"]) == 0:
self._attr_hvac_mode = HVACMode.OFF
self._attr_icon = "mdi:radiator-off"
if target_temp == 0:
if self._attr_target_temperature is None:
self._attr_target_temperature = self._attr_min_temp
else:
self._attr_hvac_mode = HVACMode.HEAT
self._attr_icon = "mdi:radiator"
self._attr_target_temperature = target_temp
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_hvac_attributes()
super()._handle_coordinator_update()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._update_hvac_attributes()

View File

@@ -18,6 +18,10 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import BooleanSelector
from homeassistant.helpers.service_info.zeroconf import (
ATTR_PROPERTIES_ID,
ZeroconfServiceInfo,
)
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN
@@ -46,6 +50,9 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_discovered_host: str
_discovered_name: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -90,6 +97,58 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery of an air-Q device."""
self._discovered_host = discovery_info.host
self._discovered_name = discovery_info.properties.get("devicename", "air-Q")
device_id = discovery_info.properties.get(ATTR_PROPERTIES_ID)
if not device_id:
return self.async_abort(reason="incomplete_discovery")
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: self._discovered_host},
reload_on_update=True,
)
self.context["title_placeholders"] = {"name": self._discovered_name}
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user confirmation of a discovered air-Q device."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
airq = AirQ(self._discovered_host, user_input[CONF_PASSWORD], session)
try:
await airq.validate()
except ClientConnectionError:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(
title=self._discovered_name,
data={
CONF_IP_ADDRESS: self._discovered_host,
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={"name": self._discovered_name},
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(

View File

@@ -7,5 +7,13 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.7"]
"requirements": ["aioairq==0.4.7"],
"zeroconf": [
{
"properties": {
"device": "air-q"
},
"type": "_http._tcp.local."
}
]
}

View File

@@ -1,14 +1,23 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_input": "[%key:common::config_flow::error::invalid_host%]"
},
"flow_title": "{name}",
"step": {
"discovery_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "Do you want to set up **{name}**?",
"title": "Set up air-Q"
},
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",

View File

@@ -117,23 +117,23 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
return super()._handle_coordinator_update()
@property
def current_temperature(self):
def current_temperature(self) -> int:
"""Return the current temperature."""
return self._unit.Temperature
@property
def fan_mode(self):
def fan_mode(self) -> str:
"""Return fan mode of the AC this group belongs to."""
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed]
@property
def fan_modes(self):
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number)
return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds]
@property
def hvac_mode(self):
def hvac_mode(self) -> HVACMode:
"""Return hvac target hvac state."""
is_off = self._unit.PowerState == "Off"
if is_off:
@@ -236,17 +236,17 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
@property
def current_temperature(self):
def current_temperature(self) -> int:
"""Return the current temperature."""
return self._unit.Temperature
@property
def target_temperature(self):
def target_temperature(self) -> int:
"""Return the temperature we are trying to reach."""
return self._unit.TargetSetpoint
@property
def hvac_mode(self):
def hvac_mode(self) -> HVACMode:
"""Return hvac target hvac state."""
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
is_off = self._unit.PowerState == "Off"
@@ -272,12 +272,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
self.async_write_ha_state()
@property
def fan_mode(self):
def fan_mode(self) -> str:
"""Return fan mode of the AC this group belongs to."""
return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed]
@property
def fan_modes(self):
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup(
self._group_number

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.3.0"]
"requirements": ["airtouch5py==0.4.0"]
}

View File

@@ -7,13 +7,7 @@ from datetime import timedelta
from math import ceil
from typing import Any
from pyairvisual.cloud_api import (
CloudAPI,
InvalidKeyError,
KeyExpiredError,
UnauthorizedError,
)
from pyairvisual.errors import AirVisualError
from pyairvisual.cloud_api import CloudAPI
from homeassistant.components import automation
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
@@ -28,14 +22,12 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import (
aiohttp_client,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_CITY,
@@ -47,8 +39,7 @@ from .const import (
INTEGRATION_TYPE_NODE_PRO,
LOGGER,
)
type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator]
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
# We use a raw string for the airvisual_pro domain (instead of importing the actual
# constant) so that we can avoid listing it as a dependency:
@@ -85,8 +76,8 @@ def async_get_cloud_api_update_interval(
@callback
def async_get_cloud_coordinators_by_api_key(
hass: HomeAssistant, api_key: str
) -> list[DataUpdateCoordinator]:
"""Get all DataUpdateCoordinator objects related to a particular API key."""
) -> list[AirVisualDataUpdateCoordinator]:
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
return [
entry.runtime_data
for entry in hass.config_entries.async_entries(DOMAIN)
@@ -180,38 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) ->
websession = aiohttp_client.async_get_clientsession(hass)
cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession)
async def async_update_data() -> dict[str, Any]:
"""Get new data from the API."""
if CONF_CITY in entry.data:
api_coro = cloud_api.air_quality.city(
entry.data[CONF_CITY],
entry.data[CONF_STATE],
entry.data[CONF_COUNTRY],
)
else:
api_coro = cloud_api.air_quality.nearest_city(
entry.data[CONF_LATITUDE],
entry.data[CONF_LONGITUDE],
)
try:
return await api_coro
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
raise ConfigEntryAuthFailed from ex
except AirVisualError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
coordinator = DataUpdateCoordinator(
coordinator = AirVisualDataUpdateCoordinator(
hass,
LOGGER,
config_entry=entry,
entry,
cloud_api,
name=async_get_geography_id(entry.data),
# We give a placeholder update interval in order to create the coordinator;
# then, below, we use the coordinator's presence (along with any other
# coordinators using the same API key) to calculate an actual, leveled
# update interval:
update_interval=timedelta(minutes=5),
update_method=async_update_data,
)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))

View File

@@ -0,0 +1,72 @@
"""Define an AirVisual data coordinator."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from pyairvisual.cloud_api import (
CloudAPI,
InvalidKeyError,
KeyExpiredError,
UnauthorizedError,
)
from pyairvisual.errors import AirVisualError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_CITY, LOGGER
type AirVisualConfigEntry = ConfigEntry[AirVisualDataUpdateCoordinator]
class AirVisualDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching AirVisual data."""
config_entry: AirVisualConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: AirVisualConfigEntry,
cloud_api: CloudAPI,
name: str,
) -> None:
"""Initialize the coordinator."""
self._cloud_api = cloud_api
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=name,
# We give a placeholder update interval in order to create the coordinator;
# then, in async_setup_entry, we use the coordinator's presence (along with
# any other coordinators using the same API key) to calculate an actual,
# leveled update interval:
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Get new data from the API."""
if CONF_CITY in self.config_entry.data:
api_coro = self._cloud_api.air_quality.city(
self.config_entry.data[CONF_CITY],
self.config_entry.data[CONF_STATE],
self.config_entry.data[CONF_COUNTRY],
)
else:
api_coro = self._cloud_api.air_quality.nearest_city(
self.config_entry.data[CONF_LATITUDE],
self.config_entry.data[CONF_LONGITUDE],
)
try:
return await api_coro
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
raise ConfigEntryAuthFailed from ex
except AirVisualError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err

View File

@@ -15,8 +15,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from . import AirVisualConfigEntry
from .const import CONF_CITY
from .coordinator import AirVisualConfigEntry
CONF_COORDINATES = "coordinates"
CONF_TITLE = "title"

View File

@@ -2,29 +2,25 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import AirVisualDataUpdateCoordinator
class AirVisualEntity(CoordinatorEntity):
class AirVisualEntity(CoordinatorEntity[AirVisualDataUpdateCoordinator]):
"""Define a generic AirVisual entity."""
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
coordinator: AirVisualDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_extra_state_attributes = {}
self._entry = entry
self.entity_description = description
async def async_added_to_hass(self) -> None:

View File

@@ -8,7 +8,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
@@ -24,10 +23,9 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import AirVisualConfigEntry
from .const import CONF_CITY
from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator
from .entity import AirVisualEntity
ATTR_CITY = "city"
@@ -113,7 +111,7 @@ async def async_setup_entry(
"""Set up AirVisual sensors based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AirVisualGeographySensor(coordinator, entry, description, locale)
AirVisualGeographySensor(coordinator, description, locale)
for locale in GEOGRAPHY_SENSOR_LOCALES
for description in GEOGRAPHY_SENSOR_DESCRIPTIONS
)
@@ -124,14 +122,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator,
entry: ConfigEntry,
coordinator: AirVisualDataUpdateCoordinator,
description: SensorEntityDescription,
locale: str,
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, description)
super().__init__(coordinator, description)
entry = coordinator.config_entry
self._attr_extra_state_attributes.update(
{
ATTR_CITY: entry.data.get(CONF_CITY),
@@ -182,16 +180,16 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity):
#
# We use any coordinates in the config entry and, in the case of a geography by
# name, we fall back to the latitude longitude provided in the coordinator data:
latitude = self._entry.data.get(
latitude = self.coordinator.config_entry.data.get(
CONF_LATITUDE,
self.coordinator.data["location"]["coordinates"][1],
)
longitude = self._entry.data.get(
longitude = self.coordinator.config_entry.data.get(
CONF_LONGITUDE,
self.coordinator.data["location"]["coordinates"][0],
)
if self._entry.options[CONF_SHOW_ON_MAP]:
if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]:
self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude
self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude
self._attr_extra_state_attributes.pop("lati", None)

View File

@@ -4,18 +4,9 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pyairvisual.node import (
InvalidAuthenticationError,
NodeConnectionError,
NodeProError,
NodeSamba,
)
from pyairvisual.node import NodeProError, NodeSamba
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_PASSWORD,
@@ -23,25 +14,16 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER
from .coordinator import (
AirVisualProConfigEntry,
AirVisualProCoordinator,
AirVisualProData,
)
PLATFORMS = [Platform.SENSOR]
UPDATE_INTERVAL = timedelta(minutes=1)
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
@dataclass
class AirVisualProData:
"""Define a data class."""
coordinator: DataUpdateCoordinator
node: NodeSamba
async def async_setup_entry(
hass: HomeAssistant, entry: AirVisualProConfigEntry
@@ -54,48 +36,15 @@ async def async_setup_entry(
except NodeProError as err:
raise ConfigEntryNotReady from err
reload_task: asyncio.Task | None = None
async def async_get_data() -> dict[str, Any]:
"""Get data from the device."""
try:
data = await node.async_get_latest_measurements()
data["history"] = {}
if data["settings"].get("follow_mode") == "device":
history = await node.async_get_history(include_trends=False)
data["history"] = history.get("measurements", [])[-1]
except InvalidAuthenticationError as err:
raise ConfigEntryAuthFailed("Invalid Samba password") from err
except NodeConnectionError as err:
nonlocal reload_task
if not reload_task:
reload_task = hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id)
)
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
except NodeProError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
return data
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
config_entry=entry,
name="Node/Pro data",
update_interval=UPDATE_INTERVAL,
update_method=async_get_data,
)
coordinator = AirVisualProCoordinator(hass, entry, node)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = AirVisualProData(coordinator=coordinator, node=node)
async def async_shutdown(_: Event) -> None:
"""Define an event handler to disconnect from the websocket."""
nonlocal reload_task
if reload_task:
if coordinator.reload_task:
with suppress(asyncio.CancelledError):
reload_task.cancel()
coordinator.reload_task.cancel()
await node.async_disconnect()
entry.async_on_unload(

View File

@@ -0,0 +1,79 @@
"""DataUpdateCoordinator for the AirVisual Pro integration."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pyairvisual.node import (
InvalidAuthenticationError,
NodeConnectionError,
NodeProError,
NodeSamba,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
UPDATE_INTERVAL = timedelta(minutes=1)
@dataclass
class AirVisualProData:
"""Define a data class."""
coordinator: AirVisualProCoordinator
node: NodeSamba
type AirVisualProConfigEntry = ConfigEntry[AirVisualProData]
class AirVisualProCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for AirVisual Pro data."""
config_entry: AirVisualProConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AirVisualProConfigEntry,
node: NodeSamba,
) -> None:
"""Initialize."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="Node/Pro data",
update_interval=UPDATE_INTERVAL,
)
self._node = node
self.reload_task: asyncio.Task[bool] | None = None
async def _async_update_data(self) -> dict[str, Any]:
"""Get data from the device."""
try:
data = await self._node.async_get_latest_measurements()
data["history"] = {}
if data["settings"].get("follow_mode") == "device":
history = await self._node.async_get_history(include_trends=False)
data["history"] = history.get("measurements", [])[-1]
except InvalidAuthenticationError as err:
raise ConfigEntryAuthFailed("Invalid Samba password") from err
except NodeConnectionError as err:
if self.reload_task is None:
self.reload_task = self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err
except NodeProError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
return data

View File

@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from . import AirVisualProConfigEntry
from .coordinator import AirVisualProConfigEntry
CONF_MAC_ADDRESS = "mac_address"
CONF_SERIAL_NUMBER = "serial_number"

View File

@@ -4,19 +4,17 @@ from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AirVisualProCoordinator
class AirVisualProEntity(CoordinatorEntity):
class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
"""Define a generic AirVisual Pro entity."""
def __init__(
self, coordinator: DataUpdateCoordinator, description: EntityDescription
self, coordinator: AirVisualProCoordinator, description: EntityDescription
) -> None:
"""Initialize."""
super().__init__(coordinator)

View File

@@ -22,7 +22,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirVisualProConfigEntry
from .coordinator import AirVisualProConfigEntry
from .entity import AirVisualProEntity

View File

@@ -66,9 +66,7 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: We can automatically remove removed devices
stale-devices: done
# Platinum
async-dependency: todo

View File

@@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
"""Get value of enable_ime option or its default value."""
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]
return bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE))

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.0"]
"requirements": ["pyanglianwater==3.1.1"]
}

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
}

View File

@@ -142,8 +142,12 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"climate",
"cover",
"device_tracker",
"door",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"lawn_mower",
"light",
"lock",
@@ -151,6 +155,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"person",
"remote",
"scene",
"schedule",
"siren",
"switch",
"text",

View File

@@ -14,6 +14,7 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -132,6 +133,7 @@ class S3BackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup.

View File

@@ -16,6 +16,7 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -129,6 +130,7 @@ class AzureStorageBackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup."""

View File

@@ -17,6 +17,7 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -230,6 +231,7 @@ class BackblazeBackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup to Backblaze B2.

View File

@@ -17,6 +17,7 @@ from .agent import (
BackupAgentError,
BackupAgentPlatformProtocol,
LocalBackupAgent,
OnProgressCallback,
)
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
@@ -41,6 +42,7 @@ from .manager import (
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
UploadBackupEvent,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
@@ -72,9 +74,11 @@ __all__ = [
"LocalBackupAgent",
"ManagerBackup",
"NewBackup",
"OnProgressCallback",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"UploadBackupEvent",
"WrittenBackup",
"async_get_manager",
"suggested_filename",

View File

@@ -14,6 +14,13 @@ from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupAgentError
class OnProgressCallback(Protocol):
"""Protocol for on_progress callback."""
def __call__(self, *, bytes_uploaded: int, **kwargs: Any) -> None:
"""Report upload progress."""
class BackupAgentUnreachableError(BackupAgentError):
"""Raised when the agent can't reach its API."""
@@ -53,12 +60,14 @@ class BackupAgent(abc.ABC):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param backup: Metadata about the backup that should be uploaded.
:param on_progress: A callback to report the number of uploaded bytes.
"""
@abc.abstractmethod

View File

@@ -11,7 +11,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename
@@ -73,6 +73,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup."""

View File

@@ -252,6 +252,15 @@ class BlockedEvent(ManagerStateEvent):
manager_state: BackupManagerState = BackupManagerState.BLOCKED
@dataclass(frozen=True, kw_only=True, slots=True)
class UploadBackupEvent(ManagerStateEvent):
"""Backup agent upload progress event."""
agent_id: str
uploaded_bytes: int
total_bytes: int
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@@ -579,9 +588,24 @@ class BackupManager:
_backup = replace(
backup, protected=should_encrypt, size=streamer.size()
)
await self.backup_agents[agent_id].async_upload_backup(
agent = self.backup_agents[agent_id]
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=bytes_uploaded,
total_bytes=_backup.size,
)
)
await agent.async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
on_progress=on_upload_progress,
)
if streamer:
await streamer.wait()
@@ -1374,9 +1398,10 @@ class BackupManager:
"""Forward event to subscribers."""
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
if not isinstance(event, (BlockedEvent, IdleEvent)):
self.last_action_event = event
if not isinstance(event, UploadBackupEvent):
self.last_event = event
if not isinstance(event, (BlockedEvent, IdleEvent)):
self.last_action_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)

View File

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==2.1.1",
"bleak-retry-connector==4.4.3",
"bleak-retry-connector==4.6.0",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.8.0"
"habluetooth==5.9.1"
]
}

View File

@@ -1,177 +0,0 @@
"""Reads vehicle status from MyBMW portal."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery,
entity_registry as er,
)
from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN
from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
SERVICE_SCHEMA = vol.Schema(
vol.Any(
{vol.Required(ATTR_VIN): cv.string},
{vol.Required(CONF_DEVICE_ID): cv.string},
)
)
DEFAULT_OPTIONS = {
CONF_READ_ONLY: False,
}
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
SERVICE_UPDATE_STATE = "update_state"
@callback
def _async_migrate_options_from_data_if_missing(
hass: HomeAssistant, entry: BMWConfigEntry
) -> None:
data = dict(entry.data)
options = dict(entry.options)
if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS):
options = dict(
DEFAULT_OPTIONS,
**{k: v for k, v in options.items() if k in DEFAULT_OPTIONS},
)
options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False)
hass.config_entries.async_update_entry(entry, data=data, options=options)
async def _async_migrate_entries(
hass: HomeAssistant, config_entry: BMWConfigEntry
) -> bool:
"""Migrate old entry."""
entity_registry = er.async_get(hass)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
replacements = {
Platform.SENSOR.value: {
"charging_level_hv": "fuel_and_battery.remaining_battery_percent",
"fuel_percent": "fuel_and_battery.remaining_fuel_percent",
"ac_current_limit": "charging_profile.ac_current_limit",
"charging_start_time": "fuel_and_battery.charging_start_time",
"charging_end_time": "fuel_and_battery.charging_end_time",
"charging_status": "fuel_and_battery.charging_status",
"charging_target": "fuel_and_battery.charging_target",
"remaining_battery_percent": "fuel_and_battery.remaining_battery_percent",
"remaining_range_total": "fuel_and_battery.remaining_range_total",
"remaining_range_electric": "fuel_and_battery.remaining_range_electric",
"remaining_range_fuel": "fuel_and_battery.remaining_range_fuel",
"remaining_fuel": "fuel_and_battery.remaining_fuel",
"remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent",
"activity": "climate.activity",
}
}
if (key := entry.unique_id.split("-")[-1]) in replacements.get(
entry.domain, []
):
new_unique_id = entry.unique_id.replace(
key, replacements[entry.domain][key]
)
_LOGGER.debug(
"Migrating entity '%s' unique_id from '%s' to '%s'",
entry.entity_id,
entry.unique_id,
new_unique_id,
)
if existing_entity_id := entity_registry.async_get_entity_id(
entry.domain, entry.platform, new_unique_id
):
_LOGGER.debug(
"Cannot migrate to unique_id '%s', already exists for '%s'",
new_unique_id,
existing_entity_id,
)
return None
return {
"new_unique_id": new_unique_id,
}
return None
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
"""Set up BMW Connected Drive from a config entry."""
_async_migrate_options_from_data_if_missing(hass, entry)
await _async_migrate_entries(hass, entry)
# Set up one data coordinator per account/config entry
coordinator = BMWDataUpdateCoordinator(
hass,
config_entry=entry,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Set up all platforms except notify
await hass.config_entries.async_forward_entry_setups(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
# set up notify platform, no entry support for notify platform yet,
# have to use discovery to load platform.
hass.async_create_task(
discovery.async_load_platform(
hass,
Platform.NOTIFY,
DOMAIN,
{CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id},
{},
)
)
# Clean up vehicles which are not assigned to the account anymore
account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles}
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
for device in device_entries:
if not device.identifiers.intersection(account_vehicles):
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)

View File

@@ -1,254 +0,0 @@
"""Reads vehicle status from BMW MyBMW portal."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.doors_windows import LockState
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
from bimmer_connected.vehicle.reports import ConditionBasedService
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import UnitSystem
from . import BMWConfigEntry
from .const import UNIT_MAP
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
ALLOWED_CONDITION_BASED_SERVICE_KEYS = {
"BRAKE_FLUID",
"BRAKE_PADS_FRONT",
"BRAKE_PADS_REAR",
"EMISSION_CHECK",
"ENGINE_OIL",
"OIL",
"TIRE_WEAR_FRONT",
"TIRE_WEAR_REAR",
"VEHICLE_CHECK",
"VEHICLE_TUV",
}
LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set()
ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = {
"ENGINE_OIL",
"TIRE_PRESSURE",
"WASHING_FLUID",
}
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set()
def _condition_based_services(
vehicle: MyBMWVehicle, unit_system: UnitSystem
) -> dict[str, Any]:
extra_attributes = {}
for report in vehicle.condition_based_services.messages:
if (
report.service_type not in ALLOWED_CONDITION_BASED_SERVICE_KEYS
and report.service_type not in LOGGED_CONDITION_BASED_SERVICE_WARNINGS
):
_LOGGER.warning(
"'%s' not an allowed condition based service (%s)",
report.service_type,
report,
)
LOGGED_CONDITION_BASED_SERVICE_WARNINGS.add(report.service_type)
continue
extra_attributes.update(_format_cbs_report(report, unit_system))
return extra_attributes
def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]:
extra_attributes: dict[str, Any] = {}
for message in vehicle.check_control_messages.messages:
if (
message.description_short not in ALLOWED_CHECK_CONTROL_MESSAGE_KEYS
and message.description_short not in LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS
):
_LOGGER.warning(
"'%s' not an allowed check control message (%s)",
message.description_short,
message,
)
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS.add(message.description_short)
continue
extra_attributes[message.description_short.lower()] = message.state.value
return extra_attributes
def _format_cbs_report(
report: ConditionBasedService, unit_system: UnitSystem
) -> dict[str, Any]:
result: dict[str, Any] = {}
service_type = report.service_type.lower()
result[service_type] = report.state.value
if report.due_date is not None:
result[f"{service_type}_date"] = report.due_date.strftime("%Y-%m-%d")
if report.due_distance.value and report.due_distance.unit:
distance = round(
unit_system.length(
report.due_distance.value,
UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit),
)
)
result[f"{service_type}_distance"] = f"{distance} {unit_system.length_unit}"
return result
@dataclass(frozen=True, kw_only=True)
class BMWBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes BMW binary_sensor entity."""
value_fn: Callable[[MyBMWVehicle], bool]
attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
BMWBinarySensorEntityDescription(
key="lids",
translation_key="lids",
device_class=BinarySensorDeviceClass.OPENING,
# device class opening: On means open, Off means closed
value_fn=lambda v: not v.doors_and_windows.all_lids_closed,
attr_fn=lambda v, u: {
lid.name: lid.state.value for lid in v.doors_and_windows.lids
},
),
BMWBinarySensorEntityDescription(
key="windows",
translation_key="windows",
device_class=BinarySensorDeviceClass.OPENING,
# device class opening: On means open, Off means closed
value_fn=lambda v: not v.doors_and_windows.all_windows_closed,
attr_fn=lambda v, u: {
window.name: window.state.value for window in v.doors_and_windows.windows
},
),
BMWBinarySensorEntityDescription(
key="door_lock_state",
translation_key="door_lock_state",
device_class=BinarySensorDeviceClass.LOCK,
# device class lock: On means unlocked, Off means locked
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
value_fn=lambda v: (
v.doors_and_windows.door_lock_state
not in {LockState.LOCKED, LockState.SECURED}
),
attr_fn=lambda v, u: {
"door_lock_state": v.doors_and_windows.door_lock_state.value
},
),
BMWBinarySensorEntityDescription(
key="condition_based_services",
translation_key="condition_based_services",
device_class=BinarySensorDeviceClass.PROBLEM,
# device class problem: On means problem detected, Off means no problem
value_fn=lambda v: v.condition_based_services.is_service_required,
attr_fn=_condition_based_services,
),
BMWBinarySensorEntityDescription(
key="check_control_messages",
translation_key="check_control_messages",
device_class=BinarySensorDeviceClass.PROBLEM,
# device class problem: On means problem detected, Off means no problem
value_fn=lambda v: v.check_control_messages.has_check_control_messages,
attr_fn=lambda v, u: _check_control_messages(v),
),
# electric
BMWBinarySensorEntityDescription(
key="charging_status",
translation_key="charging_status",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
# device class power: On means power detected, Off means no power
value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING,
is_available=lambda v: v.has_electric_drivetrain,
),
BMWBinarySensorEntityDescription(
key="connection_status",
translation_key="connection_status",
device_class=BinarySensorDeviceClass.PLUG,
value_fn=lambda v: v.fuel_and_battery.is_charger_connected,
is_available=lambda v: v.has_electric_drivetrain,
),
BMWBinarySensorEntityDescription(
key="is_pre_entry_climatization_enabled",
translation_key="is_pre_entry_climatization_enabled",
value_fn=lambda v: (
v.charging_profile.is_pre_entry_climatization_enabled
if v.charging_profile
else False
),
is_available=lambda v: v.has_electric_drivetrain,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BMW binary sensors from config entry."""
coordinator = config_entry.runtime_data
entities = [
BMWBinarySensor(coordinator, vehicle, description, hass.config.units)
for vehicle in coordinator.account.vehicles
for description in SENSOR_TYPES
if description.is_available(vehicle)
]
async_add_entities(entities)
class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity):
"""Representation of a BMW vehicle binary sensor."""
entity_description: BMWBinarySensorEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWBinarySensorEntityDescription,
unit_system: UnitSystem,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._unit_system = unit_system
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
_LOGGER.debug(
"Updating binary sensor '%s' of %s",
self.entity_description.key,
self.vehicle.name,
)
self._attr_is_on = self.entity_description.value_fn(self.vehicle)
if self.entity_description.attr_fn:
self._attr_extra_state_attributes = self.entity_description.attr_fn(
self.vehicle, self._unit_system
)
super()._handle_coordinator_update()

View File

@@ -1,127 +0,0 @@
"""Support for MyBMW button entities."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Any
from bimmer_connected.models import MyBMWAPIError
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.remote_services import RemoteServiceStatus
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, BMWConfigEntry
from .entity import BMWBaseEntity
if TYPE_CHECKING:
from .coordinator import BMWDataUpdateCoordinator
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class BMWButtonEntityDescription(ButtonEntityDescription):
"""Class describing BMW button entities."""
remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]]
enabled_when_read_only: bool = False
is_available: Callable[[MyBMWVehicle], bool] = lambda _: True
BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
BMWButtonEntityDescription(
key="light_flash",
translation_key="light_flash",
remote_function=lambda vehicle: (
vehicle.remote_services.trigger_remote_light_flash()
),
),
BMWButtonEntityDescription(
key="sound_horn",
translation_key="sound_horn",
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(),
),
BMWButtonEntityDescription(
key="activate_air_conditioning",
translation_key="activate_air_conditioning",
remote_function=lambda vehicle: (
vehicle.remote_services.trigger_remote_air_conditioning()
),
),
BMWButtonEntityDescription(
key="deactivate_air_conditioning",
translation_key="deactivate_air_conditioning",
remote_function=lambda vehicle: (
vehicle.remote_services.trigger_remote_air_conditioning_stop()
),
is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled,
),
BMWButtonEntityDescription(
key="find_vehicle",
translation_key="find_vehicle",
remote_function=lambda vehicle: (
vehicle.remote_services.trigger_remote_vehicle_finder()
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BMW buttons from config entry."""
coordinator = config_entry.runtime_data
entities: list[BMWButton] = []
for vehicle in coordinator.account.vehicles:
entities.extend(
[
BMWButton(coordinator, vehicle, description)
for description in BUTTON_TYPES
if (not coordinator.read_only and description.is_available(vehicle))
or (coordinator.read_only and description.enabled_when_read_only)
]
)
async_add_entities(entities)
class BMWButton(BMWBaseEntity, ButtonEntity):
"""Representation of a MyBMW button."""
entity_description: BMWButtonEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWButtonEntityDescription,
) -> None:
"""Initialize BMW vehicle sensor."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
async def async_press(self) -> None:
"""Press the button."""
try:
await self.entity_description.remote_function(self.vehicle)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
self.coordinator.async_update_listeners()

View File

@@ -1,277 +0,0 @@
"""Config flow for BMW ConnectedDrive integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.util.ssl import get_default_context
from . import DOMAIN
from .const import (
CONF_ALLOWED_REGIONS,
CONF_CAPTCHA_REGIONS,
CONF_CAPTCHA_TOKEN,
CONF_CAPTCHA_URL,
CONF_GCID,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
)
from .coordinator import BMWConfigEntry
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION): SelectSelector(
SelectSelectorConfig(
options=CONF_ALLOWED_REGIONS,
translation_key="regions",
)
),
},
extra=vol.REMOVE_EXTRA,
)
RECONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
},
extra=vol.REMOVE_EXTRA,
)
CAPTCHA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CAPTCHA_TOKEN): str,
},
extra=vol.REMOVE_EXTRA,
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
auth = MyBMWAuthentication(
data[CONF_USERNAME],
data[CONF_PASSWORD],
get_region_from_name(data[CONF_REGION]),
hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
verify=get_default_context(),
)
try:
await auth.login()
except MyBMWCaptchaMissingError as ex:
raise MissingCaptcha from ex
except MyBMWAuthError as ex:
raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex:
raise CannotConnect from ex
# Return info that you want to store in the config entry.
retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
if auth.refresh_token:
retval[CONF_REFRESH_TOKEN] = auth.refresh_token
if auth.gcid:
retval[CONF_GCID] = auth.gcid
return retval
class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MyBMW."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self._existing_entry_data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = self.data.pop("errors", {})
if user_input is not None and not errors:
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
await self.async_set_unique_id(unique_id)
# Unique ID cannot change for reauth/reconfigure
if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
self._abort_if_unique_id_configured()
# Store user input for later use
self.data.update(user_input)
# North America and Rest of World require captcha token
if (
self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS
and CONF_CAPTCHA_TOKEN not in self.data
):
return await self.async_step_captcha()
info = None
try:
info = await validate_input(self.hass, self.data)
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
finally:
self.data.pop(CONF_CAPTCHA_TOKEN, None)
if info:
entry_data = {
**self.data,
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
CONF_GCID: info.get(CONF_GCID),
}
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=entry_data
)
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data=entry_data,
)
return self.async_create_entry(
title=info["title"],
data=entry_data,
)
schema = self.add_suggested_values_to_schema(
DATA_SCHEMA,
self._existing_entry_data or self.data,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_change_password(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the change password step."""
if user_input is not None:
return await self.async_step_user(self._existing_entry_data | user_input)
return self.async_show_form(
step_id="change_password",
data_schema=RECONFIGURE_SCHEMA,
description_placeholders={
CONF_USERNAME: self._existing_entry_data[CONF_USERNAME],
CONF_REGION: self._existing_entry_data[CONF_REGION],
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self._existing_entry_data = dict(entry_data)
return await self.async_step_change_password()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
self._existing_entry_data = dict(self._get_reconfigure_entry().data)
return await self.async_step_change_password()
async def async_step_captcha(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show captcha form."""
if user_input and user_input.get(CONF_CAPTCHA_TOKEN):
self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip()
return await self.async_step_user(self.data)
return self.async_show_form(
step_id="captcha",
data_schema=CAPTCHA_SCHEMA,
description_placeholders={
"captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION])
},
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: BMWConfigEntry,
) -> BMWOptionsFlow:
"""Return a MyBMW option flow."""
return BMWOptionsFlow()
class BMWOptionsFlow(OptionsFlow):
"""Handle a option flow for MyBMW."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
return await self.async_step_account_options()
async def async_step_account_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is not None:
# Manually update & reload the config entry after options change.
# Required as each successful login will store the latest refresh_token
# using async_update_entry, which would otherwise trigger a full reload
# if the options would be refreshed using a listener.
changed = self.hass.config_entries.async_update_entry(
self.config_entry,
options=user_input,
)
if changed:
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="account_options",
data_schema=vol.Schema(
{
vol.Optional(
CONF_READ_ONLY,
default=self.config_entry.options.get(CONF_READ_ONLY, False),
): bool,
}
),
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class MissingCaptcha(HomeAssistantError):
"""Error to indicate the captcha token is missing."""

View File

@@ -1,34 +0,0 @@
"""Const file for the MyBMW integration."""
from homeassistant.const import UnitOfLength, UnitOfVolume
DOMAIN = "bmw_connected_drive"
ATTR_DIRECTION = "direction"
ATTR_VIN = "vin"
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"]
CONF_READ_ONLY = "read_only"
CONF_ACCOUNT = "account"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_GCID = "gcid"
CONF_CAPTCHA_TOKEN = "captcha_token"
CONF_CAPTCHA_URL = (
"https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html"
)
DATA_HASS_CONFIG = "hass_config"
UNIT_MAP = {
"KILOMETERS": UnitOfLength.KILOMETERS,
"MILES": UnitOfLength.MILES,
"LITERS": UnitOfVolume.LITERS,
"GALLONS": UnitOfVolume.GALLONS,
}
SCAN_INTERVALS = {
"china": 300,
"north_america": 600,
"rest_of_world": 300,
}

View File

@@ -1,113 +0,0 @@
"""Coordinator for BMW."""
from __future__ import annotations
from datetime import timedelta
import logging
from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import (
GPSPosition,
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import get_default_context
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
_LOGGER = logging.getLogger(__name__)
type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator]
class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching BMW data."""
account: MyBMWAccount
config_entry: BMWConfigEntry
def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None:
"""Initialize account-wide BMW data updater."""
self.account = MyBMWAccount(
config_entry.data[CONF_USERNAME],
config_entry.data[CONF_PASSWORD],
get_region_from_name(config_entry.data[CONF_REGION]),
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
verify=get_default_context(),
)
self.read_only: bool = config_entry.options[CONF_READ_ONLY]
if CONF_REFRESH_TOKEN in config_entry.data:
self.account.set_refresh_token(
refresh_token=config_entry.data[CONF_REFRESH_TOKEN],
gcid=config_entry.data.get(CONF_GCID),
)
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
update_interval=timedelta(
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
),
)
# Default to false on init so _async_update_data logic works
self.last_update_success = False
async def _async_update_data(self) -> None:
"""Fetch data from BMW."""
old_refresh_token = self.account.refresh_token
try:
await self.account.get_vehicles()
except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_captcha",
) from err
except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"exception": str(err)},
) from err
# Clear refresh token and trigger reauth if previous update failed as well
self._update_config_entry_refresh_token(None)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from err
except (MyBMWAPIError, RequestError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"exception": str(err)},
) from err
if self.account.refresh_token != old_refresh_token:
self._update_config_entry_refresh_token(self.account.refresh_token)
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
"""Update or delete the refresh_token in the Config Entry."""
data = {
**self.config_entry.data,
CONF_REFRESH_TOKEN: refresh_token,
}
if not refresh_token:
data.pop(CONF_REFRESH_TOKEN)
self.hass.config_entries.async_update_entry(self.config_entry, data=data)

View File

@@ -1,86 +0,0 @@
"""Device tracker for MyBMW vehicles."""
from __future__ import annotations
import logging
from typing import Any
from bimmer_connected.vehicle import MyBMWVehicle
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BMWConfigEntry
from .const import ATTR_DIRECTION
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW tracker from config entry."""
coordinator = config_entry.runtime_data
entities: list[BMWDeviceTracker] = []
for vehicle in coordinator.account.vehicles:
entities.append(BMWDeviceTracker(coordinator, vehicle))
if not vehicle.is_vehicle_tracking_enabled:
_LOGGER.info(
(
"Tracking is (currently) disabled for vehicle %s (%s), defaulting"
" to unknown"
),
vehicle.name,
vehicle.vin,
)
async_add_entities(entities)
class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
"""MyBMW device tracker."""
_attr_force_update = False
_attr_translation_key = "car"
_attr_name = None
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
) -> None:
"""Initialize the Tracker."""
super().__init__(coordinator, vehicle)
self._attr_unique_id = vehicle.vin
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading}
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return (
self.vehicle.vehicle_location.location[0]
if self.vehicle.is_vehicle_tracking_enabled
and self.vehicle.vehicle_location.location
else None
)
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return (
self.vehicle.vehicle_location.location[1]
if self.vehicle.is_vehicle_tracking_enabled
and self.vehicle.vehicle_location.location
else None
)

View File

@@ -1,100 +0,0 @@
"""Diagnostics support for the BMW Connected Drive integration."""
from __future__ import annotations
from dataclasses import asdict
import json
from typing import TYPE_CHECKING, Any
from bimmer_connected.utils import MyBMWJSONEncoder
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import BMWConfigEntry
from .const import CONF_REFRESH_TOKEN
PARALLEL_UPDATES = 1
if TYPE_CHECKING:
from bimmer_connected.vehicle import MyBMWVehicle
TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN]
TO_REDACT_DATA = [
"lat",
"latitude",
"lon",
"longitude",
"heading",
"vin",
"licensePlate",
"city",
"street",
"streetNumber",
"postalCode",
"phone",
"formatted",
"subtitle",
]
def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict:
"""Convert a MyBMWVehicle to a dictionary using MyBMWJSONEncoder."""
retval: dict = json.loads(json.dumps(vehicle, cls=MyBMWJSONEncoder))
return retval
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BMWConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
coordinator.account.config.log_responses = True
await coordinator.account.get_vehicles(force_init=True)
diagnostics_data = {
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
"data": [
async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA)
for vehicle in coordinator.account.vehicles
],
"fingerprint": async_redact_data(
[asdict(r) for r in coordinator.account.get_stored_responses()],
TO_REDACT_DATA,
),
}
coordinator.account.config.log_responses = False
return diagnostics_data
async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = config_entry.runtime_data
coordinator.account.config.log_responses = True
await coordinator.account.get_vehicles(force_init=True)
vin = next(iter(device.identifiers))[1]
vehicle = coordinator.account.get_vehicle(vin)
diagnostics_data = {
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
"data": async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA),
# Always have to get the full fingerprint as the VIN is redacted beforehand by the library
"fingerprint": async_redact_data(
[asdict(r) for r in coordinator.account.get_stored_responses()],
TO_REDACT_DATA,
),
}
coordinator.account.config.log_responses = False
return diagnostics_data

View File

@@ -1,40 +0,0 @@
"""Base for all BMW entities."""
from __future__ import annotations
from bimmer_connected.vehicle import MyBMWVehicle
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BMWDataUpdateCoordinator
class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]):
"""Common base for BMW entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
) -> None:
"""Initialize entity."""
super().__init__(coordinator)
self.vehicle = vehicle
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, vehicle.vin)},
manufacturer=vehicle.brand.name,
model=vehicle.name,
name=vehicle.name,
serial_number=vehicle.vin,
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()

View File

@@ -1,102 +0,0 @@
{
"entity": {
"binary_sensor": {
"charging_status": {
"default": "mdi:ev-station"
},
"check_control_messages": {
"default": "mdi:car-tire-alert"
},
"condition_based_services": {
"default": "mdi:wrench"
},
"connection_status": {
"default": "mdi:car-electric"
},
"door_lock_state": {
"default": "mdi:car-key"
},
"is_pre_entry_climatization_enabled": {
"default": "mdi:car-seat-heater"
},
"lids": {
"default": "mdi:car-door-lock"
},
"windows": {
"default": "mdi:car-door"
}
},
"button": {
"activate_air_conditioning": {
"default": "mdi:hvac"
},
"deactivate_air_conditioning": {
"default": "mdi:hvac-off"
},
"find_vehicle": {
"default": "mdi:crosshairs-question"
},
"light_flash": {
"default": "mdi:car-light-alert"
},
"sound_horn": {
"default": "mdi:bullhorn"
}
},
"device_tracker": {
"car": {
"default": "mdi:car"
}
},
"number": {
"target_soc": {
"default": "mdi:battery-charging-medium"
}
},
"select": {
"ac_limit": {
"default": "mdi:current-ac"
},
"charging_mode": {
"default": "mdi:vector-point-select"
}
},
"sensor": {
"charging_status": {
"default": "mdi:ev-station"
},
"charging_target": {
"default": "mdi:battery-charging-high"
},
"climate_status": {
"default": "mdi:fan"
},
"mileage": {
"default": "mdi:speedometer"
},
"remaining_fuel": {
"default": "mdi:gas-station"
},
"remaining_fuel_percent": {
"default": "mdi:gas-station"
},
"remaining_range_electric": {
"default": "mdi:map-marker-distance"
},
"remaining_range_fuel": {
"default": "mdi:map-marker-distance"
},
"remaining_range_total": {
"default": "mdi:map-marker-distance"
}
},
"switch": {
"charging": {
"default": "mdi:ev-station"
},
"climate": {
"default": "mdi:fan"
}
}
}
}

View File

@@ -1,121 +0,0 @@
"""Support for BMW car locks with BMW ConnectedDrive."""
from __future__ import annotations
import logging
from typing import Any
from bimmer_connected.models import MyBMWAPIError
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.doors_windows import LockState
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 1
DOOR_LOCK_STATE = "door_lock_state"
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
coordinator = config_entry.runtime_data
if not coordinator.read_only:
async_add_entities(
BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles
)
class BMWLock(BMWBaseEntity, LockEntity):
"""Representation of a MyBMW vehicle lock."""
_attr_translation_key = "lock"
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
) -> None:
"""Initialize the lock."""
super().__init__(coordinator, vehicle)
self._attr_unique_id = f"{vehicle.vin}-lock"
self.door_lock_state_available = vehicle.is_lsc_enabled
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the car."""
_LOGGER.debug("%s: locking doors", self.vehicle.name)
# Only update the HA state machine if the vehicle reliably reports its lock state
if self.door_lock_state_available:
# Optimistic state set here because it takes some time before the
# update callback response
self._attr_is_locked = True
self.async_write_ha_state()
try:
await self.vehicle.remote_services.trigger_remote_door_lock()
except MyBMWAPIError as ex:
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the car."""
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
# Only update the HA state machine if the vehicle reliably reports its lock state
if self.door_lock_state_available:
# Optimistic state set here because it takes some time before the
# update callback response
self._attr_is_locked = False
self.async_write_ha_state()
try:
await self.vehicle.remote_services.trigger_remote_door_unlock()
except MyBMWAPIError as ex:
# Set the state to unknown if the command fails
self._attr_is_locked = None
self.async_write_ha_state()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
finally:
# Always update the listeners to get the latest state
self.coordinator.async_update_listeners()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
_LOGGER.debug("Updating lock data of %s", self.vehicle.name)
# Only update the HA state machine if the vehicle reliably reports its lock state
if self.door_lock_state_available:
self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in {
LockState.LOCKED,
LockState.SECURED,
}
self._attr_extra_state_attributes = {
DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value
}
super()._handle_coordinator_update()

View File

@@ -1,11 +0,0 @@
{
"domain": "bmw_connected_drive",
"name": "BMW Connected Drive",
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected[china]==0.17.3"]
}

View File

@@ -1,113 +0,0 @@
"""Support for BMW notifications."""
from __future__ import annotations
import logging
from typing import Any, cast
from bimmer_connected.models import MyBMWAPIError, PointOfInterest
from bimmer_connected.vehicle import MyBMWVehicle
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TARGET,
BaseNotificationService,
)
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, BMWConfigEntry
PARALLEL_UPDATES = 1
ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
POI_SCHEMA = vol.Schema(
{
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Optional("street"): cv.string,
vol.Optional("city"): cv.string,
vol.Optional("postal_code"): cv.string,
vol.Optional("country"): cv.string,
}
)
_LOGGER = logging.getLogger(__name__)
def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> BMWNotificationService:
"""Get the BMW notification service."""
config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry(
(discovery_info or {})[CONF_ENTITY_ID]
)
targets = {}
if (
config_entry
and (coordinator := config_entry.runtime_data)
and not coordinator.read_only
):
targets.update({v.name: v for v in coordinator.account.vehicles})
return BMWNotificationService(targets)
class BMWNotificationService(BaseNotificationService):
"""Send Notifications to BMW."""
vehicle_targets: dict[str, MyBMWVehicle]
def __init__(self, targets: dict[str, MyBMWVehicle]) -> None:
"""Set up the notification service."""
self.vehicle_targets = targets
@property
def targets(self) -> dict[str, Any] | None:
"""Return a dictionary of registered targets."""
return self.vehicle_targets
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message or POI to the car."""
try:
# Verify data schema
poi_data = kwargs.get(ATTR_DATA) or {}
POI_SCHEMA(poi_data)
# Create the POI object
poi = PointOfInterest(
lat=poi_data.pop(ATTR_LATITUDE),
lon=poi_data.pop(ATTR_LONGITUDE),
name=(message or None),
**poi_data,
)
except (vol.Invalid, TypeError, ValueError) as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_poi",
translation_placeholders={
"poi_exception": str(ex),
},
) from ex
for vehicle in kwargs[ATTR_TARGET]:
vehicle = cast(MyBMWVehicle, vehicle)
_LOGGER.debug("Sending message to %s", vehicle.name)
try:
await vehicle.remote_services.trigger_send_poi(poi)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex

View File

@@ -1,118 +0,0 @@
"""Number platform for BMW."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from bimmer_connected.models import MyBMWAPIError
from bimmer_connected.vehicle import MyBMWVehicle
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class BMWNumberEntityDescription(NumberEntityDescription):
"""Describes BMW number entity."""
value_fn: Callable[[MyBMWVehicle], float | int | None]
remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]]
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
NUMBER_TYPES: list[BMWNumberEntityDescription] = [
BMWNumberEntityDescription(
key="target_soc",
translation_key="target_soc",
device_class=NumberDeviceClass.BATTERY,
is_available=lambda v: v.is_remote_set_target_soc_enabled,
native_max_value=100.0,
native_min_value=20.0,
native_step=5.0,
mode=NumberMode.SLIDER,
value_fn=lambda v: v.fuel_and_battery.charging_target,
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
target_soc=int(o)
),
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW number from config entry."""
coordinator = config_entry.runtime_data
entities: list[BMWNumber] = []
for vehicle in coordinator.account.vehicles:
if not coordinator.read_only:
entities.extend(
[
BMWNumber(coordinator, vehicle, description)
for description in NUMBER_TYPES
if description.is_available(vehicle)
]
)
async_add_entities(entities)
class BMWNumber(BMWBaseEntity, NumberEntity):
"""Representation of BMW Number entity."""
entity_description: BMWNumberEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWNumberEntityDescription,
) -> None:
"""Initialize an BMW Number."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
@property
def native_value(self) -> float | None:
"""Return the entity value to represent the entity state."""
return self.entity_description.value_fn(self.vehicle)
async def async_set_native_value(self, value: float) -> None:
"""Update to the vehicle."""
_LOGGER.debug(
"Executing '%s' on vehicle '%s' to value '%s'",
self.entity_description.key,
self.vehicle.vin,
value,
)
try:
await self.entity_description.remote_service(self.vehicle, value)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
self.coordinator.async_update_listeners()

View File

@@ -1,107 +0,0 @@
# + in comment indicates requirement for quality scale
# - in comment indicates issue to be fixed, not impacting quality scale
rules:
# Bronze
action-setup:
status: exempt
comment: |
Does not have custom services
appropriate-polling: done
brands: done
common-modules:
status: done
comment: |
- 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update()
config-flow-test-coverage:
status: todo
comment: |
- test_show_form doesn't really add anything
- Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports
+ Ensure that configs flows end in CREATE_ENTRY or ABORT
- Parameterize test_authentication_error, test_api_error and test_connection_error
+ test_full_user_flow_implementation doesn't assert unique id of created entry
+ test that aborts when a mocked config entry already exists
+ don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change)
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
Does not have custom services
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
This integration doesn't have any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
Does not have custom services
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: done
comment: |
- Use constants in tests where possible
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: This integration doesn't use discovery.
discovery:
status: exempt
comment: This integration doesn't use discovery.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices:
status: todo
comment: >
To be discussed.
We cannot regularly get new devices/vehicles due to API quota limitations.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
Other than reauthentication, this integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: todo
comment: >
To be discussed.
We cannot regularly check for stale devices/vehicles due to API quota limitations.
# Platinum
async-dependency: done
inject-websession:
status: todo
comment: >
To be discussed.
The library requires a custom client for API authentication, with custom auth lifecycle and user agents.
strict-typing: done

View File

@@ -1,132 +0,0 @@
"""Select platform for BMW."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from bimmer_connected.models import MyBMWAPIError
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.charging_profile import ChargingMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import UnitOfElectricCurrent
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class BMWSelectEntityDescription(SelectEntityDescription):
"""Describes BMW sensor entity."""
current_option: Callable[[MyBMWVehicle], str]
remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]]
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = (
BMWSelectEntityDescription(
key="ac_limit",
translation_key="ac_limit",
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
dynamic_options=lambda v: [
str(lim)
for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
],
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
ac_limit=int(o)
),
unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
BMWSelectEntityDescription(
key="charging_mode",
translation_key="charging_mode",
is_available=lambda v: v.is_charging_plan_supported,
options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN],
current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr]
remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update(
charging_mode=ChargingMode(o)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
coordinator = config_entry.runtime_data
entities: list[BMWSelect] = []
for vehicle in coordinator.account.vehicles:
if not coordinator.read_only:
entities.extend(
[
BMWSelect(coordinator, vehicle, description)
for description in SELECT_TYPES
if description.is_available(vehicle)
]
)
async_add_entities(entities)
class BMWSelect(BMWBaseEntity, SelectEntity):
"""Representation of BMW select entity."""
entity_description: BMWSelectEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWSelectEntityDescription,
) -> None:
"""Initialize an BMW select."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
if description.dynamic_options:
self._attr_options = description.dynamic_options(vehicle)
self._attr_current_option = description.current_option(vehicle)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
_LOGGER.debug(
"Updating select '%s' of %s", self.entity_description.key, self.vehicle.name
)
self._attr_current_option = self.entity_description.current_option(self.vehicle)
super()._handle_coordinator_update()
async def async_select_option(self, option: str) -> None:
"""Update to the vehicle."""
_LOGGER.debug(
"Executing '%s' on vehicle '%s' to value '%s'",
self.entity_description.key,
self.vehicle.vin,
option,
)
try:
await self.entity_description.remote_service(self.vehicle, option)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
self.coordinator.async_update_listeners()

View File

@@ -1,250 +0,0 @@
"""Support for reading vehicle status from MyBMW portal."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import datetime
import logging
from bimmer_connected.models import StrEnum, ValueWithUnit
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.climate import ClimateActivityState
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
STATE_UNKNOWN,
UnitOfElectricCurrent,
UnitOfLength,
UnitOfPressure,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class BMWSensorEntityDescription(SensorEntityDescription):
"""Describes BMW sensor entity."""
key_class: str | None = None
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
TIRES = ["front_left", "front_right", "rear_left", "rear_right"]
SENSOR_TYPES: list[BMWSensorEntityDescription] = [
BMWSensorEntityDescription(
key="charging_profile.ac_current_limit",
translation_key="ac_current_limit",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.charging_start_time",
translation_key="charging_start_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=False,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.charging_end_time",
translation_key="charging_end_time",
device_class=SensorDeviceClass.TIMESTAMP,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.charging_status",
translation_key="charging_status",
device_class=SensorDeviceClass.ENUM,
options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN],
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.charging_target",
translation_key="charging_target",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_battery_percent",
translation_key="remaining_battery_percent",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="mileage",
translation_key="mileage",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=0,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_range_total",
translation_key="remaining_range_total",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_range_electric",
translation_key="remaining_range_electric",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_range_fuel",
translation_key="remaining_range_fuel",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_fuel",
translation_key="remaining_fuel",
device_class=SensorDeviceClass.VOLUME_STORAGE,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
key="fuel_and_battery.remaining_fuel_percent",
translation_key="remaining_fuel_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
),
BMWSensorEntityDescription(
key="climate.activity",
translation_key="climate_status",
device_class=SensorDeviceClass.ENUM,
options=[
s.value.lower()
for s in ClimateActivityState
if s != ClimateActivityState.UNKNOWN
],
is_available=lambda v: v.is_remote_climate_stop_enabled,
),
*[
BMWSensorEntityDescription(
key=f"tires.{tire}.current_pressure",
translation_key=f"{tire}_current_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.KPA,
suggested_unit_of_measurement=UnitOfPressure.BAR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
)
for tire in TIRES
],
*[
BMWSensorEntityDescription(
key=f"tires.{tire}.target_pressure",
translation_key=f"{tire}_target_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.KPA,
suggested_unit_of_measurement=UnitOfPressure.BAR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
)
for tire in TIRES
],
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW sensors from config entry."""
coordinator = config_entry.runtime_data
entities = [
BMWSensor(coordinator, vehicle, description)
for vehicle in coordinator.account.vehicles
for description in SENSOR_TYPES
if description.is_available(vehicle)
]
async_add_entities(entities)
class BMWSensor(BMWBaseEntity, SensorEntity):
"""Representation of a BMW vehicle sensor."""
entity_description: BMWSensorEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWSensorEntityDescription,
) -> None:
"""Initialize BMW vehicle sensor."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
_LOGGER.debug(
"Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name
)
key_path = self.entity_description.key.split(".")
state = getattr(self.vehicle, key_path.pop(0))
for key in key_path:
state = getattr(state, key)
# For datetime without tzinfo, we assume it to be the same timezone as the HA instance
if isinstance(state, datetime.datetime) and state.tzinfo is None:
state = state.replace(tzinfo=dt_util.get_default_time_zone())
# For enum types, we only want the value
elif isinstance(state, ValueWithUnit):
state = state.value
# Get lowercase values from StrEnum
elif isinstance(state, StrEnum):
state = state.value.lower()
if state == STATE_UNKNOWN:
state = None
self._attr_native_value = state
super()._handle_coordinator_update()

View File

@@ -1,248 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_captcha": "Captcha validation missing"
},
"step": {
"captcha": {
"data": {
"captcha_token": "Captcha token"
},
"data_description": {
"captcha_token": "One-time token retrieved from the captcha challenge."
},
"description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.",
"title": "Are you a robot?"
},
"change_password": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]"
},
"description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive region",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password of your MyBMW/MINI Connected account.",
"region": "The region of your MyBMW/MINI Connected account.",
"username": "The email address of your MyBMW/MINI Connected account."
},
"description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data."
}
}
},
"entity": {
"binary_sensor": {
"charging_status": {
"name": "Charging status"
},
"check_control_messages": {
"name": "Check control messages"
},
"condition_based_services": {
"name": "Condition-based services"
},
"connection_status": {
"name": "Connection status"
},
"door_lock_state": {
"name": "Door lock state"
},
"is_pre_entry_climatization_enabled": {
"name": "Pre-entry climatization"
},
"lids": {
"name": "Lids"
},
"windows": {
"name": "Windows"
}
},
"button": {
"activate_air_conditioning": {
"name": "Activate air conditioning"
},
"deactivate_air_conditioning": {
"name": "Deactivate air conditioning"
},
"find_vehicle": {
"name": "Find vehicle"
},
"light_flash": {
"name": "Flash lights"
},
"sound_horn": {
"name": "Sound horn"
}
},
"lock": {
"lock": {
"name": "[%key:component::lock::title%]"
}
},
"number": {
"target_soc": {
"name": "Target SoC"
}
},
"select": {
"ac_limit": {
"name": "AC charging limit"
},
"charging_mode": {
"name": "Charging mode",
"state": {
"delayed_charging": "Delayed charging",
"immediate_charging": "Immediate charging",
"no_action": "No action"
}
}
},
"sensor": {
"ac_current_limit": {
"name": "AC current limit"
},
"charging_end_time": {
"name": "Charging end time"
},
"charging_start_time": {
"name": "Charging start time"
},
"charging_status": {
"name": "Charging status",
"state": {
"charging": "[%key:common::state::charging%]",
"complete": "Complete",
"default": "Default",
"error": "[%key:common::state::error%]",
"finished_fully_charged": "Finished, fully charged",
"finished_not_full": "Finished, not full",
"fully_charged": "Fully charged",
"invalid": "Invalid",
"not_charging": "Not charging",
"plugged_in": "Plugged in",
"target_reached": "Target reached",
"waiting_for_charging": "Waiting for charging"
}
},
"charging_target": {
"name": "Charging target"
},
"climate_status": {
"name": "Climate status",
"state": {
"cooling": "Cooling",
"heating": "Heating",
"inactive": "Inactive",
"standby": "[%key:common::state::standby%]",
"ventilation": "Ventilation"
}
},
"front_left_current_pressure": {
"name": "Front left tire pressure"
},
"front_left_target_pressure": {
"name": "Front left target pressure"
},
"front_right_current_pressure": {
"name": "Front right tire pressure"
},
"front_right_target_pressure": {
"name": "Front right target pressure"
},
"mileage": {
"name": "Mileage"
},
"rear_left_current_pressure": {
"name": "Rear left tire pressure"
},
"rear_left_target_pressure": {
"name": "Rear left target pressure"
},
"rear_right_current_pressure": {
"name": "Rear right tire pressure"
},
"rear_right_target_pressure": {
"name": "Rear right target pressure"
},
"remaining_battery_percent": {
"name": "Remaining battery percent"
},
"remaining_fuel": {
"name": "Remaining fuel"
},
"remaining_fuel_percent": {
"name": "Remaining fuel percent"
},
"remaining_range_electric": {
"name": "Remaining range electric"
},
"remaining_range_fuel": {
"name": "Remaining range fuel"
},
"remaining_range_total": {
"name": "Remaining range total"
}
},
"switch": {
"charging": {
"name": "Charging"
},
"climate": {
"name": "Climate"
}
}
},
"exceptions": {
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
"message": "Login requires captcha validation"
},
"remote_service_error": {
"message": "Error executing remote service on vehicle. {exception}"
},
"update_failed": {
"message": "Error updating vehicle data. {exception}"
}
},
"options": {
"step": {
"account_options": {
"data": {
"read_only": "Read-only mode"
},
"data_description": {
"read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state."
}
}
}
},
"selector": {
"regions": {
"options": {
"china": "China",
"north_america": "North America",
"rest_of_world": "Rest of world"
}
}
}
}

View File

@@ -1,133 +0,0 @@
"""Switch platform for BMW."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from bimmer_connected.models import MyBMWAPIError
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class BMWSwitchEntityDescription(SwitchEntityDescription):
"""Describes BMW switch entity."""
value_fn: Callable[[MyBMWVehicle], bool]
remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
CHARGING_STATE_ON = {
ChargingState.CHARGING,
ChargingState.COMPLETE,
ChargingState.FULLY_CHARGED,
ChargingState.FINISHED_FULLY_CHARGED,
ChargingState.FINISHED_NOT_FULL,
ChargingState.TARGET_REACHED,
}
NUMBER_TYPES: list[BMWSwitchEntityDescription] = [
BMWSwitchEntityDescription(
key="climate",
translation_key="climate",
is_available=lambda v: v.is_remote_climate_stop_enabled,
value_fn=lambda v: v.climate.is_climate_on,
remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(),
remote_service_off=lambda v: (
v.remote_services.trigger_remote_air_conditioning_stop()
),
),
BMWSwitchEntityDescription(
key="charging",
translation_key="charging",
is_available=lambda v: v.is_remote_charge_stop_enabled,
value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON,
remote_service_on=lambda v: v.remote_services.trigger_charge_start(),
remote_service_off=lambda v: v.remote_services.trigger_charge_stop(),
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW switch from config entry."""
coordinator = config_entry.runtime_data
entities: list[BMWSwitch] = []
for vehicle in coordinator.account.vehicles:
if not coordinator.read_only:
entities.extend(
[
BMWSwitch(coordinator, vehicle, description)
for description in NUMBER_TYPES
if description.is_available(vehicle)
]
)
async_add_entities(entities)
class BMWSwitch(BMWBaseEntity, SwitchEntity):
"""Representation of BMW Switch entity."""
entity_description: BMWSwitchEntityDescription
def __init__(
self,
coordinator: BMWDataUpdateCoordinator,
vehicle: MyBMWVehicle,
description: BMWSwitchEntityDescription,
) -> None:
"""Initialize an BMW Switch."""
super().__init__(coordinator, vehicle)
self.entity_description = description
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
@property
def is_on(self) -> bool:
"""Return the entity value to represent the entity state."""
return self.entity_description.value_fn(self.vehicle)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.entity_description.remote_service_on(self.vehicle)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
self.coordinator.async_update_listeners()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
try:
await self.entity_description.remote_service_off(self.vehicle)
except MyBMWAPIError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)},
) from ex
self.coordinator.async_update_listeners()

View File

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

View File

@@ -15,7 +15,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.9"],
"requirements": ["PyChromecast==14.0.10"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}

View File

@@ -0,0 +1,31 @@
"""The Chess.com integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import ChessConfigEntry, ChessCoordinator
_PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
"""Set up Chess.com from a config entry."""
coordinator = ChessCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,47 @@
"""Config flow for the Chess.com integration."""
from __future__ import annotations
import logging
from typing import Any
from chess_com_api import ChessComClient, NotFoundError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Chess.com."""
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:
session = async_get_clientsession(self.hass)
client = ChessComClient(session=session)
try:
user = await client.get_player(user_input[CONF_USERNAME])
except NotFoundError:
errors["base"] = "player_not_found"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(str(user.player_id))
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user.name, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}),
errors=errors,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Chess.com integration."""
DOMAIN = "chess_com"

View File

@@ -0,0 +1,57 @@
"""Coordinator for Chess.com."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from chess_com_api import ChessComAPIError, ChessComClient, Player, PlayerStats
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
type ChessConfigEntry = ConfigEntry[ChessCoordinator]
@dataclass
class ChessData:
"""Data for Chess.com."""
player: Player
stats: PlayerStats
class ChessCoordinator(DataUpdateCoordinator[ChessData]):
"""Coordinator for Chess.com."""
config_entry: ChessConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ChessConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=timedelta(hours=1),
)
self.client = ChessComClient(session=async_get_clientsession(hass))
async def _async_update_data(self) -> ChessData:
"""Update data from Chess.com."""
try:
player = await self.client.get_player(self.config_entry.data[CONF_USERNAME])
stats = await self.client.get_player_stats(
self.config_entry.data[CONF_USERNAME]
)
except ChessComAPIError as err:
raise UpdateFailed(f"Error communicating with Chess.com: {err}") from err
return ChessData(player=player, stats=stats)

View File

@@ -0,0 +1,26 @@
"""Base entity for Chess.com integration."""
from typing import TYPE_CHECKING
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ChessCoordinator
class ChessEntity(CoordinatorEntity[ChessCoordinator]):
"""Base entity for Chess.com integration."""
_attr_has_entity_name = True
def __init__(self, coordinator: ChessCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id is not None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
entry_type=DeviceEntryType.SERVICE,
manufacturer="Chess.com",
)

View File

@@ -0,0 +1,21 @@
{
"entity": {
"sensor": {
"chess_daily_rating": {
"default": "mdi:chart-line"
},
"followers": {
"default": "mdi:account-multiple"
},
"total_daily_draw": {
"default": "mdi:chess-pawn"
},
"total_daily_lost": {
"default": "mdi:chess-pawn"
},
"total_daily_won": {
"default": "mdi:chess-pawn"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"domain": "chess_com",
"name": "Chess.com",
"codeowners": ["@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/chess_com",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["chess_com_api"],
"quality_scale": "bronze",
"requirements": ["chess-com-api==1.1.0"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: There are no custom actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: There are no custom actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: There are no configuration parameters
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Can't detect a game
discovery:
status: exempt
comment: Can't detect a game
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: There are no repairable issues
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,97 @@
"""Sensor platform for Chess.com integration."""
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ChessConfigEntry
from .coordinator import ChessCoordinator, ChessData
from .entity import ChessEntity
@dataclass(kw_only=True, frozen=True)
class ChessEntityDescription(SensorEntityDescription):
"""Sensor description for Chess.com player."""
value_fn: Callable[[ChessData], float]
SENSORS: tuple[ChessEntityDescription, ...] = (
ChessEntityDescription(
key="followers",
translation_key="followers",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.player.followers,
entity_registry_enabled_default=False,
),
ChessEntityDescription(
key="chess_daily_rating",
translation_key="chess_daily_rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
),
ChessEntityDescription(
key="total_daily_won",
translation_key="total_daily_won",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["win"],
),
ChessEntityDescription(
key="total_daily_lost",
translation_key="total_daily_lost",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
),
ChessEntityDescription(
key="total_daily_draw",
translation_key="total_daily_draw",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ChessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
coordinator = entry.runtime_data
async_add_entities(
ChessPlayerSensor(coordinator, description) for description in SENSORS
)
class ChessPlayerSensor(ChessEntity, SensorEntity):
"""Chess.com sensor."""
entity_description: ChessEntityDescription
def __init__(
self,
coordinator: ChessCoordinator,
description: ChessEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}"
@property
def native_value(self) -> float:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,47 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"player_not_found": "Player not found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "Add player"
},
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"username": "The Chess.com username of the player to monitor."
}
}
}
},
"entity": {
"sensor": {
"chess_daily_rating": {
"name": "Daily chess rating"
},
"followers": {
"name": "Followers",
"unit_of_measurement": "followers"
},
"total_daily_draw": {
"name": "Total chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_lost": {
"name": "Total chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_won": {
"name": "Total chess games won",
"unit_of_measurement": "games"
}
}
}
}

View File

@@ -115,18 +115,6 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"current_temperature_changed": {
"trigger": "mdi:thermometer"
},
"current_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},

View File

@@ -372,78 +372,6 @@
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {

View File

@@ -17,15 +17,7 @@ from homeassistant.helpers.trigger import (
make_entity_transition_trigger,
)
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
@@ -53,18 +45,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
@@ -73,16 +53,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_HUMIDITY
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_HUMIDITY
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -66,20 +66,6 @@ hvac_mode_changed:
- unknown
multiple: true
current_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_humidity_changed:
target: *trigger_climate_target
fields:
@@ -94,20 +80,6 @@ target_humidity_crossed_threshold:
lower_limit: *number_or_entity
upper_limit: *number_or_entity
current_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_temperature_changed:
target: *trigger_climate_target
fields:

View File

@@ -18,6 +18,7 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
@@ -106,6 +107,7 @@ class CloudBackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup.

View File

@@ -14,6 +14,7 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -129,6 +130,7 @@ class R2BackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup.

View File

@@ -107,17 +107,17 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
return UnitOfTemperature.FAHRENHEIT
@property
def current_temperature(self):
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._unit.temperature
@property
def target_temperature(self):
def target_temperature(self) -> float:
"""Return the temperature we are trying to reach."""
return self._unit.thermostat
@property
def hvac_mode(self):
def hvac_mode(self) -> HVACMode:
"""Return hvac target hvac state."""
mode = self._unit.mode
if not self._unit.is_on:
@@ -126,7 +126,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
return CM_TO_HA_STATE[mode]
@property
def fan_mode(self):
def fan_mode(self) -> str:
"""Return the fan setting."""
# Normalize to lowercase for lookup, and pass unknown lowercase values through.
@@ -145,7 +145,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
return CM_TO_HA_FAN[fan_speed_lower]
@property
def fan_modes(self):
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
return FAN_MODES

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
from enum import IntFlag, StrEnum
import functools as ft
import logging
from typing import Any, final
@@ -33,7 +32,20 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401
from .const import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_IS_CLOSED,
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN,
INTENT_CLOSE_COVER,
INTENT_OPEN_COVER,
CoverDeviceClass,
CoverEntityFeature,
CoverState,
)
from .trigger import make_cover_closed_trigger, make_cover_opened_trigger
_LOGGER = logging.getLogger(__name__)
@@ -43,57 +55,33 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=15)
class CoverState(StrEnum):
"""State of Cover entities."""
CLOSED = "closed"
CLOSING = "closing"
OPEN = "open"
OPENING = "opening"
class CoverDeviceClass(StrEnum):
"""Device class for cover."""
# Refer to the cover dev docs for device class descriptions
AWNING = "awning"
BLIND = "blind"
CURTAIN = "curtain"
DAMPER = "damper"
DOOR = "door"
GARAGE = "garage"
GATE = "gate"
SHADE = "shade"
SHUTTER = "shutter"
WINDOW = "window"
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass))
DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass]
# mypy: disallow-any-generics
class CoverEntityFeature(IntFlag):
"""Supported features of the cover entity."""
OPEN = 1
CLOSE = 2
SET_POSITION = 4
STOP = 8
OPEN_TILT = 16
CLOSE_TILT = 32
STOP_TILT = 64
SET_TILT_POSITION = 128
ATTR_CURRENT_POSITION = "current_position"
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
ATTR_IS_CLOSED = "is_closed"
ATTR_POSITION = "position"
ATTR_TILT_POSITION = "tilt_position"
__all__ = [
"ATTR_CURRENT_POSITION",
"ATTR_CURRENT_TILT_POSITION",
"ATTR_IS_CLOSED",
"ATTR_POSITION",
"ATTR_TILT_POSITION",
"DEVICE_CLASSES",
"DEVICE_CLASSES_SCHEMA",
"DOMAIN",
"INTENT_CLOSE_COVER",
"INTENT_OPEN_COVER",
"PLATFORM_SCHEMA",
"PLATFORM_SCHEMA_BASE",
"CoverDeviceClass",
"CoverEntity",
"CoverEntityDescription",
"CoverEntityFeature",
"CoverState",
"make_cover_closed_trigger",
"make_cover_opened_trigger",
]
@bind_hass

View File

@@ -1,6 +1,52 @@
"""Constants for cover entity platform."""
from enum import IntFlag, StrEnum
DOMAIN = "cover"
ATTR_CURRENT_POSITION = "current_position"
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
ATTR_IS_CLOSED = "is_closed"
ATTR_POSITION = "position"
ATTR_TILT_POSITION = "tilt_position"
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
class CoverEntityFeature(IntFlag):
"""Supported features of the cover entity."""
OPEN = 1
CLOSE = 2
SET_POSITION = 4
STOP = 8
OPEN_TILT = 16
CLOSE_TILT = 32
STOP_TILT = 64
SET_TILT_POSITION = 128
class CoverState(StrEnum):
"""State of Cover entities."""
CLOSED = "closed"
CLOSING = "closing"
OPEN = "open"
OPENING = "opening"
class CoverDeviceClass(StrEnum):
"""Device class for cover."""
# Refer to the cover dev docs for device class descriptions
AWNING = "awning"
BLIND = "blind"
CURTAIN = "curtain"
DAMPER = "damper"
DOOR = "door"
GARAGE = "garage"
GATE = "gate"
SHADE = "shade"
SHUTTER = "shutter"
WINDOW = "window"

View File

@@ -108,5 +108,37 @@
"toggle_cover_tilt": {
"service": "mdi:arrow-top-right-bottom-left"
}
},
"triggers": {
"awning_closed": {
"trigger": "mdi:storefront-outline"
},
"awning_opened": {
"trigger": "mdi:storefront-outline"
},
"blind_closed": {
"trigger": "mdi:blinds-horizontal-closed"
},
"blind_opened": {
"trigger": "mdi:blinds-horizontal"
},
"curtain_closed": {
"trigger": "mdi:curtains-closed"
},
"curtain_opened": {
"trigger": "mdi:curtains"
},
"shade_closed": {
"trigger": "mdi:roller-shade-closed"
},
"shade_opened": {
"trigger": "mdi:roller-shade"
},
"shutter_closed": {
"trigger": "mdi:window-shutter"
},
"shutter_opened": {
"trigger": "mdi:window-shutter-open"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"close": "Close {entity_name}",
@@ -82,6 +86,15 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -136,5 +149,107 @@
"name": "Toggle tilt"
}
},
"title": "Cover"
"title": "Cover",
"triggers": {
"awning_closed": {
"description": "Triggers after one or more awnings close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Awning closed"
},
"awning_opened": {
"description": "Triggers after one or more awnings open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Awning opened"
},
"blind_closed": {
"description": "Triggers after one or more blinds close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Blind closed"
},
"blind_opened": {
"description": "Triggers after one or more blinds open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Blind opened"
},
"curtain_closed": {
"description": "Triggers after one or more curtains close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Curtain closed"
},
"curtain_opened": {
"description": "Triggers after one or more curtains open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Curtain opened"
},
"shade_closed": {
"description": "Triggers after one or more shades close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shade closed"
},
"shade_opened": {
"description": "Triggers after one or more shades open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shade opened"
},
"shutter_closed": {
"description": "Triggers after one or more shutters close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shutter closed"
},
"shutter_opened": {
"description": "Triggers after one or more shutters open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shutter opened"
}
}
}

View File

@@ -0,0 +1,107 @@
"""Provides triggers for covers."""
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.trigger import (
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
class CoverTriggerBase(EntityTriggerBase):
"""Base trigger for cover state changes."""
_binary_sensor_target_state: str
_cover_is_closed_target_value: bool
_device_classes: dict[str, str]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by cover device class."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_classes[split_entity_id(entity_id)[0]]
}
def is_valid_state(self, state: State) -> bool:
"""Check if the state matches the target cover state."""
if split_entity_id(state.entity_id)[0] == DOMAIN:
return (
state.attributes.get(ATTR_IS_CLOSED)
== self._cover_is_closed_target_value
)
return state.state == self._binary_sensor_target_state
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
return False
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
return from_state.state != to_state.state
def make_cover_opened_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_opened."""
class CoverOpenedTrigger(CoverTriggerBase):
"""Trigger for cover opened state changes."""
_binary_sensor_target_state = STATE_ON
_cover_is_closed_target_value = False
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverOpenedTrigger
def make_cover_closed_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_closed."""
class CoverClosedTrigger(CoverTriggerBase):
"""Trigger for cover closed state changes."""
_binary_sensor_target_state = STATE_OFF
_cover_is_closed_target_value = True
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverClosedTrigger
# Concrete triggers for cover device classes (cover-only, no binary sensor)
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
TRIGGERS: dict[str, type[Trigger]] = {
"awning_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_AWNING),
"awning_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_AWNING),
"blind_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_BLIND),
"blind_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_BLIND),
"curtain_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
"curtain_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
"shade_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHADE),
"shade_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHADE),
"shutter_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
"shutter_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -0,0 +1,81 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
awning_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: awning
awning_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: awning
blind_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: blind
blind_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: blind
curtain_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: curtain
curtain_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: curtain
shade_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shade
shade_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shade
shutter_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shutter
shutter_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shutter

View File

@@ -30,9 +30,16 @@ async def async_setup_entry(
async_add_entities(
[
DemoWaterHeater(
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
"demo_water_heater",
"Demo Water Heater",
119,
UnitOfTemperature.FAHRENHEIT,
False,
"eco",
1,
),
DemoWaterHeater(
"demo_water_heater_celsius",
"Demo Water Heater Celsius",
45,
UnitOfTemperature.CELSIUS,
@@ -52,6 +59,7 @@ class DemoWaterHeater(WaterHeaterEntity):
def __init__(
self,
unique_id: str,
name: str,
target_temperature: int,
unit_of_measurement: str,
@@ -60,6 +68,7 @@ class DemoWaterHeater(WaterHeaterEntity):
target_temperature_step: float,
) -> None:
"""Initialize the water_heater device."""
self._attr_unique_id = unique_id
self._attr_name = name
if target_temperature is not None:
self._attr_supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE

View File

@@ -0,0 +1,15 @@
"""Integration for door triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "door"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"closed": {
"trigger": "mdi:door-closed"
},
"opened": {
"trigger": "mdi:door-open"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "door",
"name": "Door",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/door",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Door",
"triggers": {
"closed": {
"description": "Triggers after one or more doors close.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::trigger_behavior_description%]",
"name": "[%key:component::door::common::trigger_behavior_name%]"
}
},
"name": "Door closed"
},
"opened": {
"description": "Triggers after one or more doors open.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::trigger_behavior_description%]",
"name": "[%key:component::door::common::trigger_behavior_name%]"
}
},
"name": "Door opened"
}
}
}

View File

@@ -0,0 +1,36 @@
"""Provides triggers for doors."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
DEVICE_CLASSES_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
COVER_DOMAIN: CoverDeviceClass.DOOR,
}
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for doors."""
return TRIGGERS

View File

@@ -0,0 +1,29 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
closed:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door
opened:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door

View File

@@ -490,14 +490,14 @@ class Thermostat(ClimateEntity):
return None
@property
def fan(self):
def fan(self) -> str:
"""Return the current fan status."""
if "fan" in self.thermostat["equipmentStatus"]:
return STATE_ON
return STATE_OFF
@property
def fan_mode(self):
def fan_mode(self) -> str:
"""Return the fan setting."""
return self.thermostat["runtime"]["desiredFanMode"]
@@ -535,7 +535,7 @@ class Thermostat(ClimateEntity):
return None
@property
def hvac_mode(self):
def hvac_mode(self) -> HVACMode:
"""Return current operation."""
return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]]
@@ -548,7 +548,7 @@ class Thermostat(ClimateEntity):
return None
@property
def hvac_action(self):
def hvac_action(self) -> HVACAction:
"""Return current HVAC action.
Ecobee returns a CSV string with different equipment that is active.

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.2.1"]
"requirements": ["pyeconet==0.2.2"]
}

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import EheimDigitalConfigEntry
TO_REDACT = {"emailAddr", "usrName"}
TO_REDACT = {"emailAddr", "usrName", "api_usrName", "api_password"}
async def async_get_config_entry_diagnostics(

View File

@@ -8,6 +8,7 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.reeflex import EheimDigitalReeflexUV
from eheimdigital.types import HeaterUnit
from homeassistant.components.number import (
@@ -44,6 +45,47 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
uom_fn: Callable[[_DeviceT], str] | None = None
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalNumberDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
key="daily_burn_time",
translation_key="daily_burn_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=1440,
value_fn=lambda device: device.daily_burn_time,
set_value_fn=lambda device, value: device.set_daily_burn_time(int(value)),
),
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
key="booster_time",
translation_key="booster_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=20160,
value_fn=lambda device: device.booster_time,
set_value_fn=lambda device, value: device.set_booster_time(int(value)),
),
EheimDigitalNumberDescription[EheimDigitalReeflexUV](
key="pause_time",
translation_key="pause_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=20160,
value_fn=lambda device: device.pause_time,
set_value_fn=lambda device, value: device.set_pause_time(int(value)),
),
)
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
EheimDigitalNumberDescription[EheimDigitalFilter](
key="high_pulse_time",
@@ -189,6 +231,13 @@ async def async_setup_entry(
)
for description in HEATER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalReeflexUV):
entities.extend(
EheimDigitalNumber[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
)
entities.extend(
EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description)
for description in GENERAL_DESCRIPTIONS

View File

@@ -7,9 +7,11 @@ from typing import Any, Literal, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.reeflex import EheimDigitalReeflexUV
from eheimdigital.types import (
FilterMode,
FilterModeProf,
ReeflexMode,
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
)
@@ -36,6 +38,20 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalSelectDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalSelectDescription[EheimDigitalReeflexUV](
key="mode",
translation_key="mode",
value_fn=lambda device: device.mode.name.lower(),
set_value_fn=(
lambda device, value: device.set_mode(ReeflexMode[value.upper()])
),
options=[name.lower() for name in ReeflexMode.__members__],
),
)
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
EheimDigitalSelectDescription[EheimDigitalFilter](
key="filter_mode",
@@ -176,6 +192,13 @@ async def async_setup_entry(
EheimDigitalFilterSelect(coordinator, device, description)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalReeflexUV):
entities.extend(
EheimDigitalSelect[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
)
async_add_entities(entities)

View File

@@ -58,6 +58,12 @@
}
},
"number": {
"booster_time": {
"name": "Booster duration"
},
"daily_burn_time": {
"name": "Daily burn duration"
},
"day_speed": {
"name": "Day speed"
},
@@ -76,6 +82,7 @@
"night_temperature_offset": {
"name": "Night temperature offset"
},
"pause_time": { "name": "Pause duration" },
"system_led": {
"name": "System LED brightness"
},
@@ -108,6 +115,10 @@
"manual_speed": {
"name": "Manual speed"
},
"mode": {
"name": "Operation mode",
"state": { "constant": "Constant", "daycycle": "Daycycle" }
},
"night_speed": {
"name": "Night speed"
}
@@ -127,9 +138,18 @@
"operating_time": {
"name": "Operating time"
},
"remaining_booster_time": {
"name": "Remaining booster time"
},
"remaining_pause_time": {
"name": "Remaining pause time"
},
"service_hours": {
"name": "Remaining hours until service"
},
"time_until_next_service": {
"name": "Time until next service"
},
"turn_feeding_time": {
"name": "Remaining off time after feeding"
},
@@ -137,12 +157,26 @@
"name": "Remaining off time"
}
},
"switch": {
"booster": {
"name": "Booster"
},
"expert": {
"name": "Expert mode"
},
"pause": {
"name": "Pause"
}
},
"time": {
"day_start_time": {
"name": "Day start time"
},
"night_start_time": {
"name": "Night start time"
},
"start_time": {
"name": "Start time"
}
}
},

View File

@@ -1,12 +1,16 @@
"""EHEIM Digital switches."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.reeflex import EheimDigitalReeflexUV
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -17,6 +21,50 @@ from .entity import EheimDigitalEntity, exception_handler
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSwitchDescription[_DeviceT: EheimDigitalDevice](
SwitchEntityDescription
):
"""Class describing EHEIM Digital switch entities."""
is_on_fn: Callable[[_DeviceT], bool]
set_fn: Callable[[_DeviceT, bool], Awaitable[None]]
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalSwitchDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
key="active",
name=None,
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda device: device.is_active,
set_fn=lambda device, value: device.set_active(active=value),
),
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
key="pause",
translation_key="pause",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda device: device.pause,
set_fn=lambda device, value: device.set_pause(pause=value),
),
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
key="booster",
translation_key="booster",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda device: device.booster,
set_fn=lambda device, value: device.set_booster(active=value),
),
EheimDigitalSwitchDescription[EheimDigitalReeflexUV](
key="expert",
translation_key="expert",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda device: device.expert,
set_fn=lambda device, value: device.set_expert(active=value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
@@ -32,7 +80,14 @@ async def async_setup_entry(
entities: list[SwitchEntity] = []
for device in device_address.values():
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
entities.append(EheimDigitalFilterSwitch(coordinator, device))
if isinstance(device, EheimDigitalReeflexUV):
entities.extend(
EheimDigitalSwitch[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
)
async_add_entities(entities)
@@ -40,6 +95,39 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSwitch[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], SwitchEntity
):
"""Represent a EHEIM Digital switch entity."""
entity_description: EheimDigitalSwitchDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT,
description: EheimDigitalSwitchDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital switch entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
return await self.entity_description.set_fn(self._device, True)
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
return await self.entity_description.set_fn(self._device, False)
@override
def _async_update_attrs(self) -> None:
self._attr_is_on = self.entity_description.is_on_fn(self._device)
class EheimDigitalFilterSwitch(
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
):

View File

@@ -9,6 +9,7 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.reeflex import EheimDigitalReeflexUV
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
@@ -29,6 +30,16 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
REEFLEX_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalReeflexUV], ...] = (
EheimDigitalTimeDescription[EheimDigitalReeflexUV](
key="start_time",
translation_key="start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.start_time,
set_value_fn=lambda device, value: device.set_day_start_time(value),
),
)
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
EheimDigitalTimeDescription[EheimDigitalFilter](
key="day_start_time",
@@ -118,6 +129,13 @@ async def async_setup_entry(
)
for description in HEATER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalReeflexUV):
entities.extend(
EheimDigitalTime[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
)
async_add_entities(entities)

View File

@@ -1,6 +1,6 @@
"""Support for EnOcean devices."""
from serial import SerialException
from enocean_async import Gateway
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
@@ -8,12 +8,15 @@ from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .dongle import EnOceanDongle
from .const import DOMAIN, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
type EnOceanConfigEntry = ConfigEntry[EnOceanDongle]
type EnOceanConfigEntry = ConfigEntry[Gateway]
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA
@@ -27,7 +30,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
if hass.config_entries.async_entries(DOMAIN):
# We can only have one dongle. If there is already one in the config,
# We can only have one gateway. If there is already one in the config,
# there is no need to import the yaml based config.
return True
@@ -43,23 +46,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(
hass: HomeAssistant, config_entry: EnOceanConfigEntry
) -> bool:
"""Set up an EnOcean dongle for the given entry."""
try:
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
except SerialException as err:
raise ConfigEntryNotReady(f"Failed to set up EnOcean dongle: {err}") from err
await usb_dongle.async_setup()
config_entry.runtime_data = usb_dongle
"""Set up an EnOcean gateway for the given entry."""
gateway = Gateway(port=config_entry.data[CONF_DEVICE])
gateway.add_erp1_received_callback(
lambda packet: async_dispatcher_send(hass, SIGNAL_RECEIVE_MESSAGE, packet)
)
try:
await gateway.start()
except ConnectionError as err:
gateway.stop()
raise ConfigEntryNotReady(f"Failed to start EnOcean gateway: {err}") from err
config_entry.runtime_data = gateway
config_entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_SEND_MESSAGE, gateway.send_esp3_packet)
)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: EnOceanConfigEntry
) -> bool:
"""Unload EnOcean config entry."""
enocean_dongle = config_entry.runtime_data
enocean_dongle.unload()
"""Unload EnOcean config entry: stop the gateway."""
config_entry.runtime_data.stop()
return True

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from enocean.utils import combine_hex
from enocean_async import ERP1Telegram
import voluptuous as vol
from homeassistant.components.binary_sensor import (
@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import EnOceanEntity
from .entity import EnOceanEntity, combine_hex
DEFAULT_NAME = "EnOcean binary sensor"
DEPENDENCIES = ["enocean"]
@@ -68,29 +68,25 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity):
self._attr_unique_id = f"{combine_hex(dev_id)}-{device_class}"
self._attr_name = dev_name
def value_changed(self, packet):
def value_changed(self, telegram: ERP1Telegram) -> None:
"""Fire an event with the data that have changed.
This method is called when there is an incoming packet associated
with this platform.
Example packet data:
- 2nd button pressed
['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30']
- button released
['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20']
"""
if not self.address:
return
# Energy Bow
pushed = None
if packet.data[6] == 0x30:
if telegram.status == 0x30:
pushed = 1
elif packet.data[6] == 0x20:
elif telegram.status == 0x20:
pushed = 0
self.schedule_update_ha_state()
action = packet.data[1]
action = telegram.telegram_data[0]
if action == 0x70:
self.which = 0
self.onoff = 0
@@ -112,7 +108,7 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity):
self.hass.bus.fire(
EVENT_BUTTON_PRESSED,
{
"id": self.dev_id,
"id": self.address.to_bytelist(),
"pushed": pushed,
"which": self.which,
"onoff": self.onoff,

View File

@@ -1,7 +1,9 @@
"""Config flows for the EnOcean integration."""
import glob
from typing import Any
from enocean_async import Gateway
import voluptuous as vol
from homeassistant.components import usb
@@ -19,7 +21,6 @@ from homeassistant.helpers.selector import (
)
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import dongle
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
MANUAL_SCHEMA = vol.Schema(
@@ -29,6 +30,24 @@ MANUAL_SCHEMA = vol.Schema(
)
def _detect_usb_dongle() -> list[str]:
"""Return a list of candidate paths for USB EnOcean dongles.
This method is currently a bit simplistic, it may need to be
improved to support more configurations and OS.
"""
globs_to_test = [
"/dev/tty*FTOA2PV*",
"/dev/serial/by-id/*EnOcean*",
"/dev/tty.usbserial-*",
]
found_paths = []
for current_glob in globs_to_test:
found_paths.extend(glob.glob(current_glob))
return found_paths
class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle the enOcean config flows."""
@@ -107,7 +126,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_manual()
return await self.async_step_manual(user_input)
devices = await self.hass.async_add_executor_job(dongle.detect)
devices = await self.hass.async_add_executor_job(_detect_usb_dongle)
if len(devices) == 0:
return await self.async_step_manual()
devices.append(self.MANUAL_PATH_VALUE)
@@ -146,7 +165,17 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
async def validate_enocean_conf(self, user_input) -> bool:
"""Return True if the user_input contains a valid dongle path."""
dongle_path = user_input[CONF_DEVICE]
return await self.hass.async_add_executor_job(dongle.validate_path, dongle_path)
try:
# Starting the gateway will raise an exception if it can't connect
gateway = Gateway(port=dongle_path)
await gateway.start()
except ConnectionError as exception:
LOGGER.warning("Dongle path %s is invalid: %s", dongle_path, str(exception))
return False
finally:
gateway.stop()
return True
def create_enocean_entry(self, user_input):
"""Create an entry for the provided configuration."""

View File

@@ -1,88 +0,0 @@
"""Representation of an EnOcean dongle."""
import glob
import logging
from os.path import basename, normpath
from enocean.communicators import SerialCommunicator
from enocean.protocol.packet import RadioPacket
import serial
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
_LOGGER = logging.getLogger(__name__)
class EnOceanDongle:
"""Representation of an EnOcean dongle.
The dongle is responsible for receiving the EnOcean frames,
creating devices if needed, and dispatching messages to platforms.
"""
def __init__(self, hass, serial_path):
"""Initialize the EnOcean dongle."""
self._communicator = SerialCommunicator(
port=serial_path, callback=self.callback
)
self.serial_path = serial_path
self.identifier = basename(normpath(serial_path))
self.hass = hass
self.dispatcher_disconnect_handle = None
async def async_setup(self):
"""Finish the setup of the bridge and supported platforms."""
self._communicator.start()
self.dispatcher_disconnect_handle = async_dispatcher_connect(
self.hass, SIGNAL_SEND_MESSAGE, self._send_message_callback
)
def unload(self):
"""Disconnect callbacks established at init time."""
if self.dispatcher_disconnect_handle:
self.dispatcher_disconnect_handle()
self.dispatcher_disconnect_handle = None
def _send_message_callback(self, command):
"""Send a command through the EnOcean dongle."""
self._communicator.send(command)
def callback(self, packet):
"""Handle EnOcean device's callback.
This is the callback function called by python-enocean whenever there
is an incoming packet.
"""
if isinstance(packet, RadioPacket):
_LOGGER.debug("Received radio packet: %s", packet)
dispatcher_send(self.hass, SIGNAL_RECEIVE_MESSAGE, packet)
def detect():
"""Return a list of candidate paths for USB EnOcean dongles.
This method is currently a bit simplistic, it may need to be
improved to support more configurations and OS.
"""
globs_to_test = ["/dev/tty*FTOA2PV*", "/dev/serial/by-id/*EnOcean*"]
found_paths = []
for current_glob in globs_to_test:
found_paths.extend(glob.glob(current_glob))
return found_paths
def validate_path(path: str):
"""Return True if the provided path points to a valid serial port, False otherwise."""
try:
# Creating the serial communicator will raise an exception
# if it cannot connect
SerialCommunicator(port=path)
except serial.SerialException as exception:
_LOGGER.warning("Dongle path %s is invalid: %s", path, str(exception))
return False
return True

View File

@@ -1,12 +1,23 @@
"""Representation of an EnOcean device."""
from enocean.protocol.packet import Packet
from enocean.utils import combine_hex
from enocean_async import EURID, Address, BaseAddress, ERP1Telegram, SenderAddress
from enocean_async.esp3.packet import ESP3Packet, ESP3PacketType
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.entity import Entity
from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
from .const import LOGGER, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE
def combine_hex(dev_id: list[int]) -> int:
"""Combine list of integer values to one big integer.
This function replaces the previously used function from the enocean library and is considered tech debt that will have to be replaced.
"""
value = 0
for byte in dev_id:
value = (value << 8) | (byte & 0xFF)
return value
class EnOceanEntity(Entity):
@@ -14,7 +25,16 @@ class EnOceanEntity(Entity):
def __init__(self, dev_id: list[int]) -> None:
"""Initialize the device."""
self.dev_id = dev_id
self.address: SenderAddress | None = None
try:
address = Address.from_bytelist(dev_id)
if address.is_eurid():
self.address = EURID.from_number(address.to_number())
elif address.is_base_address():
self.address = BaseAddress.from_number(address.to_number())
except ValueError:
self.address = None
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -24,17 +44,25 @@ class EnOceanEntity(Entity):
)
)
def _message_received_callback(self, packet):
def _message_received_callback(self, telegram: ERP1Telegram) -> None:
"""Handle incoming packets."""
if not self.address:
return
if packet.sender_int == combine_hex(self.dev_id):
self.value_changed(packet)
if telegram.sender == self.address:
self.value_changed(telegram)
def value_changed(self, packet):
def value_changed(self, telegram: ERP1Telegram) -> None:
"""Update the internal state of the device when a packet arrives."""
def send_command(self, data, optional, packet_type):
"""Send a command via the EnOcean dongle."""
packet = Packet(packet_type, data=data, optional=optional)
dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet)
def send_command(
self, data: list[int], optional: list[int], packet_type: ESP3PacketType
) -> None:
"""Send a command via the EnOcean dongle, if data and optional are valid bytes; otherwise, ignore."""
try:
packet = ESP3Packet(packet_type, data=bytes(data), optional=bytes(optional))
dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet)
except ValueError as err:
LOGGER.warning(
"Failed to send command: invalid data or optional bytes: %s", err
)

View File

@@ -5,7 +5,8 @@ from __future__ import annotations
import math
from typing import Any
from enocean.utils import combine_hex
from enocean_async import ERP1Telegram
from enocean_async.esp3.packet import ESP3PacketType
import voluptuous as vol
from homeassistant.components.light import (
@@ -20,7 +21,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import EnOceanEntity
from .entity import EnOceanEntity, combine_hex
CONF_SENDER_ID = "sender_id"
@@ -75,7 +76,8 @@ class EnOceanLight(EnOceanEntity, LightEntity):
command = [0xA5, 0x02, bval, 0x01, 0x09]
command.extend(self._sender_id)
command.extend([0x00])
self.send_command(command, [], 0x01)
packet_type = ESP3PacketType(0x01)
self.send_command(command, [], packet_type)
self._attr_is_on = True
def turn_off(self, **kwargs: Any) -> None:
@@ -83,17 +85,18 @@ class EnOceanLight(EnOceanEntity, LightEntity):
command = [0xA5, 0x02, 0x00, 0x01, 0x09]
command.extend(self._sender_id)
command.extend([0x00])
self.send_command(command, [], 0x01)
packet_type = ESP3PacketType(0x01)
self.send_command(command, [], packet_type)
self._attr_is_on = False
def value_changed(self, packet):
def value_changed(self, telegram: ERP1Telegram) -> None:
"""Update the internal state of this device.
Dimmer devices like Eltako FUD61 send telegram in different RORGs.
We only care about the 4BS (0xA5).
"""
if packet.data[0] == 0xA5 and packet.data[1] == 0x02:
val = packet.data[2]
if telegram.rorg == 0xA5 and telegram.telegram_data[0] == 0x02:
val = telegram.telegram_data[1]
self._attr_brightness = math.floor(val / 100.0 * 256.0)
self._attr_is_on = bool(val != 0)
self.schedule_update_ha_state()

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