Compare commits

..

265 Commits

Author SHA1 Message Date
Paul Bottein
df06a5878c Add windows 98 labs feature 2026-03-10 09:41:49 +01:00
Panda-NZ
a36733c4dc Add ambient temperature range controls to ToGrill integration (#165235) 2026-03-09 23:40:30 +01:00
Bram Kragten
bf846e0756 Validate reorder is only used when multiple is true (#165216) 2026-03-09 22:32:02 +01:00
Erik Montnemery
c037dad093 Add humidity triggers (#165197) 2026-03-09 20:34:26 +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
Andreas Jakl
5fe2ab93ff Add device tracker to NRGkick integration (#164804)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-05 12:00:30 +01:00
Glenn de Haan
0e4698eb99 Add device class to active_liter_lpm sensor (#164809) 2026-03-05 11:50:37 +01:00
epenet
698c5eca00 Migrate remaining netgear coordinators to separate module (#164826) 2026-03-05 11:49:28 +01:00
Raphael Hehl
c7776057b7 Enforce SSRF redirect protection only for connector allowed_protocol_schema_set (#164769)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-03-05 11:45:05 +01:00
Erik Montnemery
e87c677cc4 Improve homee tests (#164820) 2026-03-05 11:15:50 +01:00
Erik Montnemery
c3858a0841 Improve tuya diagnostic tests (#164819) 2026-03-05 11:13:01 +01:00
Michael
42bc5c3a5f Add remote.turned_on and remote.turned_off triggers (#164535)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-05 10:52:29 +01:00
epenet
76bc58da2c Add base NetgearDataCoordinator to netgear (#164816) 2026-03-05 10:52:12 +01:00
epenet
fc8719ce35 Remove caio from licenses exception list (#164806) 2026-03-05 10:18:08 +01:00
dependabot[bot]
60a4a97d9c Bump dawidd6/action-download-artifact from 14 to 16 (#164790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 10:16:23 +01:00
Erwin Douna
284721e1df Bump pyportainer 1.0.32 (#164803) 2026-03-05 09:06:46 +01:00
Norbert Rittel
bfa707d79e Use common string for "host" in devialet config flow (#164798) 2026-03-05 08:32:46 +01:00
Norbert Rittel
633e2e7469 Use common state for "medium" in smartthings (#164799) 2026-03-05 08:32:35 +01:00
dependabot[bot]
ad1c6846e7 Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#164791) 2026-03-05 07:29:59 +01:00
Erwin Douna
f75140b626 Add const to Portainer for endpoint up (#164746) 2026-03-05 00:38:59 +01:00
rappenze
f83757da7c Use unique fibaro_id in test fixtures (#164763) 2026-03-04 22:04:38 +00:00
Norbert Rittel
ca338c98f3 Clarify description of vacuum.clean_area action (#164764) 2026-03-04 21:57:59 +00:00
Ian Foster
18a8afb017 Update keyboard_remote dependencies (#164755) 2026-03-04 19:47:17 +01:00
Italo Lombardi
0136e9c7eb ISS integration: better entity handling (#159050)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-03-04 17:46:48 +01:00
Erik Montnemery
d88c736016 Add is_closed state attribute to cover (#164739) 2026-03-04 16:54:06 +01:00
Robert Resch
780dc178a1 Use Python version file in CI for setting the default python version (#164751) 2026-03-04 16:53:31 +01:00
Petro31
b7ba945dfc Fix this variable preview issue with template entities from the UI (#164740) 2026-03-04 16:01:41 +01:00
Magnus Øverli
01de7052af Add deprecation timeline to flexit_bacnet fireplace switch (#164450) 2026-03-04 15:47:40 +01:00
Allen Porter
3fe6a31ee9 Improve Roborock device info creation and enhance device registration for disabled or failed devices. (#164553) 2026-03-04 15:45:51 +01:00
rappenze
95570643ec Fix handling of several thermostat QuickApp's in fibaro (#164344)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-04 15:40:49 +01:00
starkillerOG
e3210b0ab9 Fix Reolink entity unique_id migration when unique_id already exists (#164667) 2026-03-04 15:12:26 +01:00
Artur Pragacz
2edabf903a Add backup integration to recovery mode (#164734) 2026-03-04 14:33:28 +01:00
Stefan Agner
0e4e703b64 Ignore transient empty segments in Matter vacuum (#164737)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-04 14:24:28 +01:00
tobiaswaldvogel
88624f5179 Use jog up/down in motionblinds if no tilt position is available (#164694)
Signed-off-by: Tobias Waldvogel <tobias.waldvogel@gmail.com>
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
2026-03-04 13:27:47 +01:00
Erwin Douna
4a5fdfc0ec Bump pyportainer 1.0.31 (#164733) 2026-03-04 13:26:10 +01:00
Bram Kragten
c6e91afae4 Update frontend to 20260304.0 (#164736) 2026-03-04 13:25:57 +01:00
Kamil Breguła
db5e7e4521 Refactor AWS S3 tests (#164098)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-04 13:13:43 +01:00
Joakim Plate
25489c224b Restore handling of is active input for chromecast (#164735) 2026-03-04 13:10:10 +01:00
Tom
c4f64598a0 Add informative errors to Proxmox VE buttons (#164417) 2026-03-04 12:48:17 +01:00
starkillerOG
59e579cf5a Bump reolink-aio to 0.19.1 (#164732) 2026-03-04 12:46:38 +01:00
epenet
831c28cf2c Migrate netgear to use runtime_data (#164718) 2026-03-04 11:37:05 +01:00
Erik Montnemery
be1affc6ba Pin exact Python version in .python-version (#164722) 2026-03-04 11:21:44 +01:00
J. Diego Rodríguez Royo
94a25b5688 Improve mobile_app notify.notify with not connected targets (#161855) 2026-03-04 11:11:02 +01:00
AlCalzone
382940d661 Support Z-Wave Hoppe eHandle tilt sensor (#164689) 2026-03-04 11:00:24 +01:00
Brett Adams
b8e1c0cf2c Fix teslemetry time_of_use service tariff double-wrapping (#164702)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-04 09:59:52 +01:00
TheJulianJES
0d23d8dc09 Bump ZHA to 1.0.1 (#164709) 2026-03-04 09:57:07 +01:00
dependabot[bot]
b750de1e3e Bump actions/ai-inference from 2.0.6 to 2.0.7 (#164713)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 09:45:00 +01:00
hanwg
7d7e8e0bde Add support for http webhook for Telegram bot (#162690) 2026-03-04 09:18:02 +01:00
Joost Lekkerkerker
d6f355355f Add cleaning type select to SmartThings (#164472)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-03-04 07:18:18 +01:00
Simone Chemelli
5dad64e54c Bump aioamazondevices to 13.0.0 (#164618) 2026-03-03 22:16:07 +00:00
Robert Resch
c311ff0464 Fix wheels building by using arch dependent requirements_all file (#164675) 2026-03-03 21:55:59 +01:00
Dave T
c45675a01f Add additional diagnostic sensors to aurora_abb_powerone PV inverter (#164622) 2026-03-03 21:34:44 +01:00
erikbadman
9d92141812 Add support for active power limit in Kostal Plenticore (#164674) 2026-03-03 21:33:54 +01:00
Robin Lintermann
501b973a98 Add send diagnostics button to smarla (#164335) 2026-03-03 21:31:31 +01:00
Kamil Breguła
fd4d8137da Change reconfiguration-flow status to 'todo' in WebDAV (#164637)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 21:23:24 +01:00
Miguel Angel Nubla
33881c1912 Fix infinite loop in esphome assist_satellite (#163097)
Co-authored-by: Artur Pragacz <artur@pragacz.com>
2026-03-03 20:44:36 +01:00
Robin Lintermann
9bdb03dbe8 Set device classes and measurement units for Smarla (#164682) 2026-03-03 18:36:02 +00:00
epenet
d2178ba458 Cleanup deprecated tuya entities (#164657) 2026-03-03 19:31:09 +01:00
Abílio Costa
06cdf3c5d2 Add PR review Claude skill (#164626) 2026-03-03 18:21:51 +00:00
r2xj
84c994ab80 Add support for samsungce.lamp as light entity and when not under main component (#164448)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-03 18:29:36 +01:00
Abílio Costa
1d5913d7a5 Simplify copilot-instructions.md script to use file refs (#164686) 2026-03-03 17:17:25 +00:00
epenet
05acba37c7 Remove deprecated YAML import from nederlandse_spoorwegen (#164662) 2026-03-03 17:59:29 +01:00
Samuel Xiao
7496406156 Bumb switchbot api to v2.11.0 (#164663) 2026-03-03 17:59:03 +01:00
epenet
543f2b1396 Improve type hints in meteoclimatic (#164651) 2026-03-03 17:57:54 +01:00
epenet
3df2bbda80 Bump tuya-device-handlers to 0.0.11 (#164586) 2026-03-03 17:57:36 +01:00
epenet
b661d37a86 Move mutesync coordinator to separate module (#164600) 2026-03-03 17:57:11 +01:00
Ariel Ebersberger
2102babc6d Influxdb repair issue follow up (#164684) 2026-03-03 17:57:09 +01:00
epenet
f3a1cab582 Migrate motionblinds_ble to runtime_data (#164601) 2026-03-03 17:56:54 +01:00
epenet
03c9ce25c8 Simplify access to motioneye client (#164599) 2026-03-03 17:56:16 +01:00
Christian Lackas
8fcabcec16 Fix HomematicIP heating group availability with unreachable members (#162571) 2026-03-03 17:34:14 +01:00
Michael Hansen
2a33096074 Bump intents to 2026.3.3 (#164676) 2026-03-03 17:26:44 +01:00
Ariel Ebersberger
14a9eada09 Add repair issue after importing influxdb yaml config (#164145)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 16:33:25 +01:00
tobiaswaldvogel
4a00f78e90 Add missing cover entity features to motion_blinds (#164673)
Signed-off-by: Tobias Waldvogel <tobias.waldvogel@gmail.com>
2026-03-03 16:30:55 +01:00
starkillerOG
abef46864e Fix key error in Reolink DHCP if still setting up (#164619) 2026-03-03 16:12:30 +01:00
Willem-Jan van Rootselaar
73b28f1ee2 Bump python-bsblan to 5.1.1 (#164591) 2026-03-03 15:56:07 +01:00
epenet
7379d41393 Migrate met_eireann to runtime_data (#164607) 2026-03-03 15:55:12 +01:00
epenet
89acb02519 Migrate monoprice to runtime_data (#164604) 2026-03-03 15:54:48 +01:00
Paul Tarjan
e343e90da2 Fix Reolink camera updates persisting in UI (#161149)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 15:40:32 +01:00
Daniel Schneider
e9a576494b Bump ring-doorbell to 0.9.14 (#158074)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-03 15:36:26 +01:00
TimL
4e047b56d8 Bump pysmlight to v0.2.16 (#164665)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-03 14:47:54 +01:00
epenet
a1e95c483d Migrate metoffice to runtime_data (#164606) 2026-03-03 14:19:57 +01:00
Andreas Jakl
9cb6e02c5f Add binary sensor platform and tests to NRGkick integration (#164629)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-03 13:55:10 +01:00
epenet
2c75e3289a Improve device_info type hints in mobile_app (#164655) 2026-03-03 13:40:56 +01:00
reneboer
348012a6b8 Bump renault-api to 0.5.6 (#164664) 2026-03-03 12:52:41 +01:00
Michael
e0db00e089 Allow the creation of multi-domain triggers (#164628) 2026-03-03 12:52:27 +01:00
Thomas Pfeiffer
b2280198d9 Add equalizer switch for Cambridge Audio devices (#162956) 2026-03-03 12:51:24 +01:00
Artur Pragacz
9cc4a3e427 Trigger recovery mode on registry major version downgrade (#164340) 2026-03-03 11:46:32 +01:00
Raman Gupta
f94a075641 Decouple Vizio apps coordinator from config entry (#163923)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-03 11:22:41 +01:00
hanwg
f1856e6ef6 Update subentry description for Telegram bot (#164642)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 11:21:01 +01:00
mettolen
ed35bafa6c Bump pysaunum to 0.6.0 (#164530) 2026-03-03 11:18:02 +01:00
Manu
66e16d728b Bump python-xbox to 0.2.0 (#164616) 2026-03-03 11:10:14 +01:00
Matthias Alphart
a806efa7e2 Update knx-frontend to 2026.3.2.183756 (#164623) 2026-03-03 11:08:20 +01:00
Norman Yee
ad4b4bd221 Enhance GV5140 test to assert temperature and humidity sensors (#164644) 2026-03-03 11:05:32 +01:00
David Recordon
c9c9a149b6 Bump pylutron-caseta to 0.27.0 (#164614) 2026-03-03 11:03:12 +01:00
epenet
0f9fdfe2de Fix invalid device registry identifiers in eafm (#164654)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 11:02:59 +01:00
Abílio Costa
a76b63912d Add Ubisys virtual integration (#164314) 2026-03-03 10:00:57 +00:00
Joshua Monta
bc03e13d38 Bump uhooapi to 1.2.8 (#164648) 2026-03-03 10:59:32 +01:00
Colin
450aa9757d Bump python-openevse-http to 0.2.5 (#164641) 2026-03-03 10:54:58 +01:00
Tom Matheussen
158389a4f2 Remove deprecated YAML import from Satel Integra (#164469) 2026-03-03 10:24:23 +01:00
Raman Gupta
95e89d5ef1 Redact zwave_js dsk key from diagnostics (#164636)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:01:35 +01:00
dependabot[bot]
e107b8e5cd Bump actions/download-artifact from 7.0.0 to 8.0.0 (#164647)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 08:34:36 +01:00
epenet
f875b43ede Remove unnecessary suppress in importlib helper (#164323) 2026-03-03 01:00:32 +01:00
Jeff Terrace
6242ef78c4 Move ONVIF event parsing into a module outside core (#164550)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-02 12:18:05 -10:00
Abílio Costa
3c342c0768 Add infrared platform to ESPHome (#162346)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 22:00:47 +00:00
Norman Yee
5dba5fc79d Add Govee H5140 CO2 monitor support to govee_ble (#164365)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-02 20:12:48 +00:00
950 changed files with 31454 additions and 31425 deletions

View File

@@ -1,8 +0,0 @@
---
name: ban-word-list
description: Find words that are not allowed
---
# Ban Word List
If any of the words listed in the `list.md` file are found on new code, warn the user and ask them to change it.

View File

@@ -1 +0,0 @@
- potato

View File

@@ -0,0 +1,46 @@
---
name: github-pr-reviewer
description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR.
---
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.
3. Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
- Suggest improvements where appropriate
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
```

View File

@@ -1,3 +1,5 @@
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
# GitHub Copilot & Claude Code Instructions
@@ -5,50 +7,22 @@ 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
## Development Commands
- **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
.vscode/tasks.json contains useful commands used for development.
### 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
## Python Syntax Notes
## Code Quality Standards
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
- **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)
## Good practices
### 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
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.
### Skill files
- ban-word-list: /.claude/skills/ban-word-list/SKILL.md
# Skills
- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md

View File

@@ -10,7 +10,6 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.2"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
@@ -42,10 +41,10 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
- name: Get information
id: info
@@ -80,7 +79,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
@@ -112,7 +111,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -123,7 +122,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -132,11 +131,11 @@ jobs:
workflow_conclusion: success
name: package
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
- name: Adjust nightly version
if: needs.init.outputs.channel == 'dev'
@@ -182,7 +181,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations
@@ -538,13 +537,13 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations
@@ -615,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

@@ -41,8 +41,7 @@ env:
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.4"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -166,6 +165,11 @@ jobs:
tests_glob=""
lint_only=""
skip_coverage=""
default_python=$(cat .python-version)
all_python_versions=$(jq -cn \
--arg default_python "${default_python}" \
--argjson additional_python_versions "${ADDITIONAL_PYTHON_VERSIONS}" \
'[$default_python] + $additional_python_versions')
if [[ "${INTEGRATION_CHANGES}" != "[]" ]];
then
@@ -235,8 +239,8 @@ jobs:
echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT
echo "postgresql_groups: ${postgresql_groups}"
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
echo "python_versions: ${ALL_PYTHON_VERSIONS}"
echo "python_versions=${ALL_PYTHON_VERSIONS}" >> $GITHUB_OUTPUT
echo "python_versions: ${all_python_versions}"
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
echo "test_full_suite: ${test_full_suite}"
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
echo "integrations_glob: ${integrations_glob}"
@@ -452,7 +456,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -503,13 +507,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -540,13 +544,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -576,11 +580,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Run gen_copilot_instructions.py
run: |
@@ -653,7 +657,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json
- name: Upload licenses
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -682,13 +686,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -735,13 +739,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -786,11 +790,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Generate partial mypy restore key
id: generate-mypy-key
@@ -798,7 +802,7 @@ jobs:
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
echo "version=${mypy_version}" >> $GITHUB_OUTPUT
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -879,13 +883,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- name: Restore full Python virtual environment
id: cache-venv
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
@@ -901,7 +905,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -978,7 +982,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: pytest_buckets
- name: Compile English translations
@@ -1020,14 +1024,14 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1040,7 +1044,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
@@ -1177,7 +1181,7 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1185,7 +1189,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1199,7 +1203,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-mariadb-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1338,7 +1342,7 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1346,7 +1350,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1360,7 +1364,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-postgres-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1387,7 +1391,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1514,14 +1518,14 @@ jobs:
2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1534,7 +1538,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
@@ -1558,7 +1562,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1587,7 +1591,7 @@ jobs:
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
steps:
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: test-results-*
- name: Upload test results to Codecov

View File

@@ -236,7 +236,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -62,7 +62,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -15,9 +15,6 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
DEFAULT_PYTHON: "3.14.2"
jobs:
upload:
name: Upload
@@ -29,10 +26,10 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
- name: Upload Translations
env:

View File

@@ -16,9 +16,6 @@ on:
- "requirements.txt"
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.14.2"
permissions: {}
concurrency:
@@ -36,11 +33,11 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version-file: ".python-version"
check-latest: true
- name: Create Python virtual environment
@@ -77,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: env_file
path: ./.env_file
@@ -85,7 +82,7 @@ jobs:
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -97,7 +94,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -124,12 +121,12 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_diff
@@ -175,17 +172,17 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_all_wheels
@@ -209,4 +206,4 @@ jobs:
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txt"
requirements: "requirements_all_wheels_${{ matrix.arch }}.txt"

View File

@@ -1 +1 @@
3.14
3.14.2

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.

27
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,6 +571,8 @@ 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
@@ -737,6 +743,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 +794,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 +1208,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 +1315,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 +1660,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 +1701,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

@@ -70,7 +70,7 @@ from .const import (
SIGNAL_BOOTSTRAP_INTEGRATIONS,
)
from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError
from .exceptions import HomeAssistantError, UnsupportedStorageVersionError
from .helpers import (
area_registry,
category_registry,
@@ -236,9 +236,19 @@ DEFAULT_INTEGRATIONS = {
"input_text",
"schedule",
"timer",
#
# Base platforms:
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.
"backup",
"cloud",
"frontend",
}
DEFAULT_INTEGRATIONS_SUPERVISOR = {
@@ -433,32 +443,56 @@ def _init_blocking_io_modules_in_executor() -> None:
is_docker_env()
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
"""Load the registries and modules that will do blocking I/O."""
async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
"""Load the registries and modules that will do blocking I/O.
Return whether loading succeeded.
"""
if DATA_REGISTRIES_LOADED in hass.data:
return
return True
hass.data[DATA_REGISTRIES_LOADED] = None
entity.async_setup(hass)
frame.async_setup(hass)
template.async_setup(hass)
translation.async_setup(hass)
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)),
create_eager_task(category_registry.async_load(hass)),
create_eager_task(device_registry.async_load(hass)),
create_eager_task(entity_registry.async_load(hass)),
create_eager_task(floor_registry.async_load(hass)),
create_eager_task(issue_registry.async_load(hass)),
create_eager_task(label_registry.async_load(hass)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
recovery = hass.config.recovery_mode
try:
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass, load_empty=recovery)),
create_eager_task(category_registry.async_load(hass, load_empty=recovery)),
create_eager_task(device_registry.async_load(hass, load_empty=recovery)),
create_eager_task(entity_registry.async_load(hass, load_empty=recovery)),
create_eager_task(floor_registry.async_load(hass, load_empty=recovery)),
create_eager_task(issue_registry.async_load(hass, load_empty=recovery)),
create_eager_task(label_registry.async_load(hass, load_empty=recovery)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass, load_empty=recovery)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
except UnsupportedStorageVersionError as err:
# If we're already in recovery mode, we don't want to handle the exception
# and activate recovery mode again, as that would lead to an infinite loop.
if recovery:
raise
_LOGGER.error(
"Storage file %s was created by a newer version of Home Assistant"
" (storage version %s > %s); activating recovery mode; on-disk data"
" is preserved; upgrade Home Assistant or restore from a backup",
err.storage_key,
err.found_version,
err.max_supported_version,
)
return False
return True
async def async_from_config_dict(
@@ -475,7 +509,9 @@ async def async_from_config_dict(
# Prime custom component cache early so we know if registry entries are tied
# to a custom integration
await loader.async_get_custom_components(hass)
await async_load_base_functionality(hass)
if not await async_load_base_functionality(hass):
return None
# Set up core.
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)

View File

@@ -0,0 +1,5 @@
{
"domain": "ubisys",
"name": "Ubisys",
"iot_standards": ["zigbee"]
}

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

@@ -44,7 +44,7 @@ def make_entity_state_trigger_required_features(
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_domains = {domain}
_to_states = {to_state}
_required_features = required_features

View File

@@ -1,6 +1,6 @@
"""Defines a base Alexa Devices entity."""
from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo
@@ -25,19 +25,20 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_num = serial_num
model_details = coordinator.api.get_model_details(self.device) or {}
model = model_details.get("model")
model = self.device.model
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_num)},
name=self.device.account_name,
model=model,
model_id=self.device.device_type,
manufacturer=model_details.get("manufacturer", "Amazon"),
hw_version=model_details.get("hw_version"),
manufacturer=self.device.manufacturer or "Amazon",
hw_version=self.device.hardware_version,
sw_version=(
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
self.device.software_version
if model != SPEAKER_GROUP_DEVICE_TYPE
else None
),
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
)
self.entity_description = description
self._attr_unique_id = f"{serial_num}-{description.key}"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==12.0.2"]
"requirements": ["aioamazondevices==13.0.0"]
}

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

@@ -61,7 +61,13 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
frequency = self.client.measure(4)
i_leak_dcdc = self.client.measure(6)
i_leak_inverter = self.client.measure(7)
power_in_1 = self.client.measure(8)
power_in_2 = self.client.measure(9)
temperature_c = self.client.measure(21)
voltage_in_1 = self.client.measure(23)
current_in_1 = self.client.measure(25)
voltage_in_2 = self.client.measure(26)
current_in_2 = self.client.measure(27)
r_iso = self.client.measure(30)
energy_wh = self.client.cumulated_energy(5)
[alarm, *_] = self.client.alarms()
@@ -87,7 +93,13 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
data["grid_frequency"] = round(frequency, 1)
data["i_leak_dcdc"] = i_leak_dcdc
data["i_leak_inverter"] = i_leak_inverter
data["power_in_1"] = round(power_in_1, 1)
data["power_in_2"] = round(power_in_2, 1)
data["temp"] = round(temperature_c, 1)
data["voltage_in_1"] = round(voltage_in_1, 1)
data["current_in_1"] = round(current_in_1, 1)
data["voltage_in_2"] = round(voltage_in_2, 1)
data["current_in_2"] = round(current_in_2, 1)
data["r_iso"] = r_iso
data["totalenergy"] = round(energy_wh / 1000, 2)
data["alarm"] = alarm

View File

@@ -68,6 +68,7 @@ SENSOR_TYPES = [
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
translation_key="grid_frequency",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
@@ -88,6 +89,60 @@ SENSOR_TYPES = [
translation_key="i_leak_inverter",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="power_in_1",
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
translation_key="power_in_1",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="power_in_2",
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
translation_key="power_in_2",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="voltage_in_1",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_key="voltage_in_1",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="current_in_1",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="current_in_1",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="voltage_in_2",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_key="voltage_in_2",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="current_in_2",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="current_in_2",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="alarm",
device_class=SensorDeviceClass.ENUM,

View File

@@ -24,9 +24,18 @@
"alarm": {
"name": "Alarm status"
},
"current_in_1": {
"name": "String 1 current"
},
"current_in_2": {
"name": "String 2 current"
},
"grid_current": {
"name": "Grid current"
},
"grid_frequency": {
"name": "Grid frequency"
},
"grid_voltage": {
"name": "Grid voltage"
},
@@ -36,6 +45,12 @@
"i_leak_inverter": {
"name": "Inverter leak current"
},
"power_in_1": {
"name": "String 1 power"
},
"power_in_2": {
"name": "String 2 power"
},
"power_output": {
"name": "Power output"
},
@@ -44,6 +59,12 @@
},
"total_energy": {
"name": "Total energy"
},
"voltage_in_1": {
"name": "String 1 voltage"
},
"voltage_in_2": {
"name": "String 2 voltage"
}
}
}

View File

@@ -142,14 +142,19 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"climate",
"cover",
"device_tracker",
"door",
"fan",
"garage_door",
"humidifier",
"humidity",
"lawn_mower",
"light",
"lock",
"media_player",
"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

@@ -29,12 +29,17 @@ class StoredBackupData(TypedDict):
class _BackupStore(Store[StoredBackupData]):
"""Class to help storing backup data."""
# Maximum version we support reading for forward compatibility.
# This allows reading data written by a newer HA version after downgrade.
_MAX_READABLE_VERSION = 2
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize storage class."""
super().__init__(
hass,
STORAGE_VERSION,
STORAGE_KEY,
max_readable_version=self._MAX_READABLE_VERSION,
minor_version=STORAGE_VERSION_MINOR,
)
@@ -86,8 +91,8 @@ class _BackupStore(Store[StoredBackupData]):
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
# planned to happen after a 6 month quiet period with no minor version
# changes.
# Reject if major version is higher than 2.
if old_major_version > 2:
# Reject if major version is higher than _MAX_READABLE_VERSION.
if old_major_version > self._MAX_READABLE_VERSION:
raise NotImplementedError
return data

View File

@@ -24,7 +24,7 @@ class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domain: str = DOMAIN
_domains = {DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""

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.0"],
"requirements": ["python-bsblan==5.1.2"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -14,7 +14,7 @@ from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -29,6 +29,12 @@
"early_update": {
"default": "mdi:update"
},
"equalizer": {
"default": "mdi:equalizer",
"state": {
"off": "mdi:equalizer-outline"
}
},
"pre_amp": {
"default": "mdi:volume-high",
"state": {

View File

@@ -65,6 +65,9 @@
"early_update": {
"name": "Early update"
},
"equalizer": {
"name": "Equalizer"
},
"pre_amp": {
"name": "Pre-Amp"
},

View File

@@ -33,6 +33,13 @@ def room_correction_enabled(client: StreamMagicClient) -> bool:
return client.audio.tilt_eq.enabled
def equalizer_enabled(client: StreamMagicClient) -> bool:
"""Check if equalizer is enabled."""
if TYPE_CHECKING:
assert client.audio.user_eq is not None
return client.audio.user_eq.enabled
CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
CambridgeAudioSwitchEntityDescription(
key="pre_amp",
@@ -56,6 +63,14 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
value_fn=room_correction_enabled,
set_value_fn=lambda client, value: client.set_room_correction_mode(value),
),
CambridgeAudioSwitchEntityDescription(
key="equalizer",
translation_key="equalizer",
entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.audio.user_eq is not None,
value_fn=equalizer_enabled,
set_value_fn=lambda client, value: client.set_equalizer_mode(value),
),
)

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

@@ -804,8 +804,22 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
# The lovelace app loops media to prevent timing out, don't show that
if (chromecast := self._chromecast) is None or (
cast_status := self.cast_status
) is None:
# Not connected to any chromecast, or not yet got any status
return None
if (
chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST
and not chromecast.ignore_cec
and cast_status.is_active_input is False
):
# The display interface for the device has been turned off or switched away
return MediaPlayerState.OFF
if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
# The lovelace app loops media to prevent timing out, don't show that
return MediaPlayerState.PLAYING
if (media_status := self._media_status()[0]) is not None:
@@ -822,16 +836,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
if self.app_id is not None and self.app_id != pychromecast.config.APP_BACKDROP:
# We have an active app
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
# If library consider us idle, that is our off state
# it takes HDMI status into account for cast devices.
if self.app_id in (pychromecast.IDLE_APP_ID, None):
# We have no active app or the home screen app. This is
# same app as APP_BACKDROP.
return MediaPlayerState.OFF
return None
return MediaPlayerState.IDLE
@property
def media_content_id(self) -> str | None:

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"
@@ -43,7 +35,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
@@ -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

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.13"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.3"]
}

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,56 +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_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
@@ -267,7 +256,9 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
data = {}
data: dict[str, Any] = {}
data[ATTR_IS_CLOSED] = self.is_closed
if (current := self.current_cover_position) is not None:
data[ATTR_CURRENT_POSITION] = current

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"
}
}
}

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