Compare commits

...

289 Commits

Author SHA1 Message Date
G Johansson
66897a247a Fix 2026-04-07 15:36:54 +00:00
G Johansson
5ae4bed868 Fix double reloading in unifi 2026-04-05 15:38:25 +00:00
Raphael Hehl
735adb61a3 Fix blocking SSL context creation in unifi_access integration (#167422) 2026-04-05 17:32:23 +02:00
Daniel Feinberg
42f602a2fd Bump python-roborock from 5.0.0 to 5.3.0 (#167437)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 07:25:01 -07:00
TimL
287b38bebd Bump pysmlight to 0.3.2 (#167421) 2026-04-05 12:02:59 +02:00
Simone Chemelli
415f711039 Bump aiocomelit to 2.0.2 (#167414) 2026-04-05 12:02:32 +02:00
G-Two
28ddb3a590 Bump subarulink to 0.7.19 (#167386) 2026-04-05 12:01:40 +02:00
lzghzr
0f1dbba65a Bump xiaomi-ble to 1.10.1 (#167384) 2026-04-05 11:59:31 +02:00
Patrick
206d1ab3a8 Bump starlink-grpc-core to 1.2.5 (#167195) 2026-04-05 11:58:46 +02:00
Marco Sousa
cb15014dc0 Bump aiopvpc to 4.3.1 (#167189) 2026-04-05 11:57:27 +02:00
Simone Chemelli
a30fc9e9d5 Align and cleanup tests data for Fritz (#167363) 2026-04-05 10:43:24 +02:00
Jonas
b5480da68e Add manual and remaining watering time to Gardena Bluetooth for Aquaprecise (#167381) 2026-04-04 21:13:57 +02:00
Joost Lekkerkerker
a5a8cca424 Bump aiohue to 4.8.1 (#167369) 2026-04-04 20:59:34 +02:00
Niracler
5c890a8a2b Use exception translations in sunricher_dali (#166858)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-04 16:43:56 +02:00
Jan Weltmeyer
dc45f86beb Add battery and supply voltage sensors to traccar_server (#167247)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 16:43:45 +02:00
MoonDevLT
5ef506623d Change attribute that is used as unique ID for lunatone (#165200)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-04 16:07:45 +02:00
Matt Philips
5632d308dd Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 15:50:35 +02:00
Niracler
43210b845b Mark icon-translations as exempt in sunricher_dali (#166857)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 15:35:25 +02:00
Erwin Douna
d798aad2d7 Add kill button to Portainer (#167277)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-04 15:16:39 +02:00
007hacky007
4232db480a Bump afsapi to 0.3.1 (#167321) 2026-04-04 14:55:36 +02:00
J. Nick Koston
8b7d1f60a0 Bump dbus-fast to 4.0.4 (#167135) 2026-04-04 13:55:47 +02:00
Simone Chemelli
cb69e638b4 100% coverage of device_tracker for Vodafone Station (#165824) 2026-04-04 13:04:56 +02:00
Tom Matheussen
f3a020780d Bump satel_integra to 1.1.0 (#167353) 2026-04-04 12:29:24 +02:00
Erwin Douna
d1224e3f92 Refactor Proxmox async_setup (#167328) 2026-04-04 12:09:49 +02:00
Franck Nijhof
ef8a0a404c Fix lingering task in TTS stream override tests (#167356) 2026-04-04 12:06:26 +02:00
Simone Chemelli
c86eb38cdb Automate device tracker cleanup process for Fritz (#166864) 2026-04-04 11:01:19 +02:00
Franck Nijhof
2333e9e8b1 Extract serialization template functions into a serialization Jinja2 extension (#167332) 2026-04-04 10:08:54 +02:00
J. Nick Koston
f8476e4e84 Bump habluetooth to 6.0.0 (#167340) 2026-04-03 17:50:47 -10:00
Noah Husby
d904175a2f Bump aiorussound to 4.10.0 (#167341) 2026-04-04 05:36:33 +02:00
Franck Nijhof
193720b4c6 Pin actions/helpers/info to fix release build (#167327) 2026-04-03 22:51:44 +02:00
Marc Mueller
409b3f17db Fix apple_tv RuntimeWarnings in tests (#167325) 2026-04-03 21:59:05 +02:00
Norbert Rittel
c3c1c3eb46 Improve Shopping List action naming consistency (#167248) 2026-04-03 22:57:00 +03:00
Erwin Douna
6b2a4df6e0 Bump pyportainer 1.0.36 (#167319) 2026-04-03 21:09:05 +02:00
g4bri3lDev
2ac3979f83 Add opendisplay encryption support (#167251) 2026-04-03 20:11:10 +02:00
Raj Laud
2fb44bce5d Fix victron ble reauth flow title (#167307)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 19:48:41 +02:00
MarkGodwin
d187e61274 Update to tplink-omada-client 1.5.7 (#167313) 2026-04-03 19:46:53 +02:00
Erwin Douna
7f79da2f75 Add prune volumes button to Portainer (#167314) 2026-04-03 19:45:55 +02:00
Ludovic BOUÉ
4871344138 Add Hisense AC (0x138C/0x0101) to Matter dry and fan mode device lists (#167282)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-03 19:41:41 +02:00
Ludovic BOUÉ
b0ba740024 Matter Pir unoccupied to occupied delay (#162435)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-03 18:44:29 +02:00
epenet
b6f49d2063 Refactor None handling in renault diagnostics (#167295) 2026-04-03 18:01:36 +02:00
Franck Nijhof
c8e2a2b520 Extract type casting template functions into a type cast Jinja2 extension (#167280) 2026-04-03 18:00:58 +02:00
epenet
27365a4457 Refactor None handling renault device_tracker (#167298) 2026-04-03 17:54:51 +02:00
epenet
4efcb5a700 Use PEP-695 syntax in Renault sensors (#167301) 2026-04-03 17:54:07 +02:00
Denis Shulyaka
4ffc4b8f71 Allow users to overwrite content type for AI task attachments (#167302) 2026-04-03 17:45:00 +02:00
Pete Sage
e8fa61ae63 Sonos alarm switch entities may not be created when speaker offline initially (#167303) 2026-04-03 17:42:09 +02:00
Simone Chemelli
c6c469cc7a Migrate image unique_id for Fritz (#167209) 2026-04-03 16:39:35 +02:00
Richard Polzer
d51afe20e0 Clarify ekeybionyx config flow oauth2 implementation handling (#167169)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-03 15:45:06 +02:00
epenet
5dac5ef099 Add missing availability check in device_tracker _async_write_ha_state (#167297) 2026-04-03 15:44:23 +02:00
Pete Sage
a1b93b418b Bump soco to 0.30.15 (#167299) 2026-04-03 15:38:59 +02:00
dependabot[bot]
22a96583a9 Bump codecov/codecov-action from 5.5.3 to 6.0.0 (#167267)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:22:12 +02:00
Norbert Rittel
01ffa6a676 Improve Recorder action naming consistency (#167244) 2026-04-03 15:21:58 +02:00
epenet
abf37849fb Remove unnecessary attribute from Renault sensor entity descriptions (#167268) 2026-04-03 15:21:36 +02:00
Ludovic BOUÉ
72cd7ed178 Fix to allow Matter Fan percent setting to be null when FanMode is Auto (#167279)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-03 15:21:14 +02:00
Erwin Douna
6305ea8cf2 Bump pyportainer 1.0.35 (#167288) 2026-04-03 15:20:49 +02:00
Ludovic BOUÉ
815c30b213 Fix Matter water heater off mode (#167286)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-03 15:20:17 +02:00
Joost Lekkerkerker
68cc6df3e0 Make sure we take all Zinvolt battery units in account (#167294) 2026-04-03 15:13:17 +02:00
Norbert Rittel
7f700c891a Improve Media player action naming consistency (#167274)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-03 15:11:35 +02:00
Joost Lekkerkerker
e33727a75a Bump Zinvolt to 0.4.1 (#167296) 2026-04-03 15:09:33 +02:00
Bram Kragten
374a050636 Update frontend to 20260325.6 (#167285) 2026-04-03 14:29:17 +02:00
Wendelin
90045e5539 Fix zwave_js subscribe_rebuild_routes_progress initial event (#167178) 2026-04-03 14:18:37 +02:00
Joost Lekkerkerker
e30c379979 Bump zinvolt to 0.4.0 (#167276) 2026-04-03 14:11:20 +02:00
Norbert Rittel
86d80c96d7 Improve Assist satellite action naming consistency (#167278) 2026-04-03 12:36:54 +02:00
Jan Bouwhuis
1bbecec991 Fix tuya energy sensor units (#160392) 2026-04-03 11:49:40 +02:00
dotlambda
3970a27369 Bump psutil to 7.2.2 (#167263) 2026-04-03 11:38:19 +02:00
Kevin O'Brien
745107c192 Fix Proxmox VE storage usage percentage crash on missing used_fraction (#167136)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 10:41:09 +02:00
Norbert Rittel
24530d13af Fix spelling of "cannot" in dwd_weather_warnings error string (#167138) 2026-04-03 10:04:32 +02:00
epenet
7529b9252c Remove unnecessary None checks in Renault numbers and binary sensors (#167271) 2026-04-03 10:00:55 +02:00
g4bri3lDev
92a1c4568d Bump py-opendisplay version to 5.9.0 (#167250) 2026-04-03 10:00:51 +02:00
Hai-Nam Nguyen
c6527c9f6d Bump hyponcloud to 0.9.3 (#167273) 2026-04-03 10:00:35 +02:00
Andrew Jackson
05b7fa9602 Remove Transmission port forward sensor (#167269) 2026-04-03 09:49:38 +02:00
Joakim Plate
375bd55ae6 Update arcam to 1.8.3 (#167249) 2026-04-02 23:39:00 +02:00
LTek
fbd0cb8666 Fix Ring snapshots (#164337)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-02 23:37:14 +02:00
Max R
7e061262ad Add pre-commit hook for copilot instructions (#167219) 2026-04-02 22:35:53 +01:00
G Johansson
0e9c17fb16 Bump holiday library to 0.93 (#167217) 2026-04-02 23:32:36 +02:00
G Johansson
940de5ea84 Fix SMHI (#167212) 2026-04-02 23:28:44 +02:00
Marc Mueller
bf4773d9bc Fix asyncio loop scopes for pytest fixtures (#166758) 2026-04-02 22:25:17 +02:00
Abílio Costa
0bedcc55ce Set codeowners for agent configurations (#167222) 2026-04-02 19:45:09 +02:00
32u-nd
313f97fc47 Add missing mHz docstrings (#167226) 2026-04-02 19:18:57 +02:00
epenet
b7d32e0650 Adjust git commit guidelines for AI agents (#167184) 2026-04-02 17:20:17 +01:00
Pete Sage
b9afb2a861 Fix Sonos reporting wrong state when media title is whitespace (#167223) 2026-04-02 17:02:14 +01:00
32u-nd
d3a01d4c80 Add millihertz (mHz) to UnitOfFrequency (#167087)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-04-02 17:27:40 +02:00
Steve Easley
05bd2d05f5 Bump pykaleidescape to v1.1.5 (#167203) 2026-04-02 15:59:01 +02:00
Joost Lekkerkerker
23469d8950 Remove not implemented supported feature from Wiim (#167205) 2026-04-02 15:58:07 +02:00
Erwin Douna
26f677dcd1 Portainer refactor async_setup (#166544) 2026-04-02 15:55:50 +02:00
Stefan Agner
484d9b0cbe Fix test_receive_backup test error when run in isolation (#167204) 2026-04-02 15:36:24 +02:00
tzagim
03ed46aa07 Bump python-telegram-bot to 22.7 (#167062) 2026-04-02 15:14:58 +02:00
Joost Lekkerkerker
e5f4000ac2 Add manufacturer to Ecowitt device (#167199) 2026-04-02 14:56:13 +02:00
Norbert Rittel
406598dbfa Fix agreement mismatch and spelling of "cannot" in nmbs (#167137) 2026-04-02 14:47:42 +02:00
epenet
7f23a35155 Migrate qnap to use runtime_data (#167198)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:47:09 +02:00
epenet
0e521eda2e Migrate qbittorrent to use runtime_data (#167196)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:46:28 +02:00
Kurt Chrisford
5a72dc8eca Add exception translations to Actron Air (#167159) 2026-04-02 14:45:42 +02:00
epenet
b11292385f Use runtime_data in ovo_energy (#167141)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:45:19 +02:00
epenet
2179a5405a Migrate progettihwsw to use runtime_data (#167157)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:45:08 +02:00
epenet
78f5989cd6 Migrate permobil to use runtime_data (#167170)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:44:35 +02:00
Norbert Rittel
cca44c675c Fix spelling of "cannot" in climate exception string (#167139) 2026-04-02 14:41:26 +02:00
Ariel Ebersberger
0ebe65c25b Fix hydrawise crashes when controllers/zones are added (#166708) 2026-04-02 14:32:57 +02:00
dependabot[bot]
f7ee95c4b9 Bump sigstore/cosign-installer from 4.1.0 to 4.1.1 (#167156)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 14:11:33 +02:00
Erik Montnemery
d78c05ab62 Propagate the in_zones attribute from device trackers in person entities (#167192) 2026-04-02 14:09:57 +02:00
epenet
9f41e3341f Migrate peco to use runtime_data (#167147)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:48:58 +02:00
epenet
8ab3d482b9 Migrate prusalink to use runtime_data (#167164)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:48:06 +02:00
epenet
a485c3d410 Migrate prosegur to use runtime_data (#167161)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:00:06 +02:00
Erik Montnemery
38b27d624a Add new state attribute in_zones to device_tracker (#166573) 2026-04-02 12:24:35 +02:00
Manu
f437d65d3c Remove deprecated LANnouncer integration (#166838) 2026-04-02 12:06:47 +02:00
Erik Montnemery
5ba0764a87 Fix propagation of GPS accuracy in person entity (#167174) 2026-04-02 11:58:48 +02:00
epenet
69fd6532cc Migrate openhome to use runtime_data (#167183)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:51:31 +02:00
epenet
ee8bd9f016 Migrate picnic to use runtime_data (#167151)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:48:49 +02:00
epenet
de5a2d47a5 Migrate pushbullet to use runtime_data (#167166)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:48:39 +02:00
epenet
54b2e0285c Migrate panasonic_viera to use runtime_data (#167171)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:48:36 +02:00
epenet
a0e118d411 Migrate mutesync to use runtime_data (#167180)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-02 11:47:51 +02:00
epenet
07c33233ee Migrate nibe_heatpump to use runtime_data (#167181)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:47:30 +02:00
rrooggiieerr
962cac902b Add Config Flow to Pico TTS (#163114)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-04-02 11:41:47 +02:00
epenet
9ff5c9863f Migrate openexchangerates to use runtime_data (#167182)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:40:10 +02:00
epenet
b60e396241 Migrate pvoutput to use runtime_data (#167167)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:03:16 +02:00
Mike Degatano
6e567ced92 Wrap hassio import in is_hassio check in get_system_info helper (#167111) 2026-04-02 09:05:09 +02:00
Raphael Hehl
e1c1e9a8b2 Bump unifi-discovery to version 1.3.0 (#167106) 2026-04-02 00:11:13 +02:00
Norbert Rittel
25b66be84d Fix spelling of "cannot" in two user-facing strings of reolink (#167085) 2026-04-01 22:37:21 +02:00
Jon Culver
4d6a278137 Add Off mode support for water_heater entities in HomeKit (#166836)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:29:13 +02:00
Denis Shulyaka
7a77b071a2 Add coordinator to Anthropic for availability check (#164615)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-01 22:20:56 +02:00
Norbert Rittel
279c9e71df Improve google_sheets action naming consistency (#167107) 2026-04-01 21:57:55 +02:00
DeerMaximum
2881916c91 Replace NINA attributes with sensors (#161882) 2026-04-01 21:53:28 +02:00
Norbert Rittel
f09602363c Improve system_log action naming consistency (#167104) 2026-04-01 21:42:59 +02:00
Norbert Rittel
79b37bff0b Improve shelly action naming consistency (#167102) 2026-04-01 22:15:19 +03:00
Tom
7c549870b5 Add firmware update to Ubiquiti airOS (#166913) 2026-04-01 20:48:06 +02:00
Abílio Costa
e50b7f41aa Simplify claude's integrations skill (#166903) 2026-04-01 20:41:35 +02:00
Kevin O'Brien
efc8053027 Fix Proxmox VE backup status sensor false positive due to case mismatch (#167069)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:32:37 +02:00
Brett Adams
d104a1126f Fix Tesla Fleet charge current scope handling (#166919) 2026-04-01 20:26:18 +02:00
Joost Lekkerkerker
a573ef4b1c Use subentry helper in WAQI (#167061) 2026-04-01 20:20:33 +02:00
Joost Lekkerkerker
83e8c3fc19 Revert "Pull out Dropbox integration" (#166995) 2026-04-01 20:19:16 +02:00
Abílio Costa
cd0ed42941 Make the Claude's GH reviewer skill a subagent (#167065) 2026-04-01 20:16:34 +02:00
Manu
2beca6b322 Fix websocket calling async_release_notes in update component although unavailable (#167067) 2026-04-01 20:10:39 +02:00
Andres Ruiz
0fc62c3150 Add support for energy statistics in waterfurnace integration (#166707)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-01 20:03:15 +02:00
johanzander
7daaf3de6a growatt_server: implement reconfiguration flow (Gold) (#165961)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:56:53 +02:00
Abílio Costa
6470cbeada Add --draft flag to raise-pull-request agent PR creation command (#167068)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:53:33 +02:00
Joost Lekkerkerker
983bade8c5 Bump pySmartThings to 3.7.3 (#167075) 2026-04-01 19:52:05 +02:00
Franck Nijhof
9d27b9290c Merge branch 'master' into dev 2026-04-01 17:50:14 +00:00
Norbert Rittel
d9acf64904 Fix one misspelled occurrence of "cannot" in shelly (#167093) 2026-04-01 19:49:38 +02:00
Norbert Rittel
cc1114de63 Fix spelling of "cannot" in local_file error string (#167089) 2026-04-01 19:48:54 +02:00
Bram Kragten
bff97254d7 Fix select condition state selector (#167064) 2026-04-01 19:41:46 +02:00
Norbert Rittel
6355adc6de Fix spelling of "cannot" in rehlko exception string (#167092) 2026-04-01 19:29:49 +02:00
Norbert Rittel
879d9176bd Fix spelling of "cannot" in azure_storage exception string (#167088) 2026-04-01 19:26:59 +02:00
Norbert Rittel
a3badd0a83 Spelling fixes in user-facing strings of wiz (#167091) 2026-04-01 19:24:01 +02:00
Simone Chemelli
73da736ebb Patch the correct socket method in SNMP (#167081) 2026-04-01 18:55:53 +02:00
Norbert Rittel
c077538015 Fix spelling of "cannot" in pooldose exception string (#167079) 2026-04-01 18:46:34 +02:00
Norbert Rittel
33bcd710fc Fix spelling of "Cannot reheat …" in kitchen_sink (#167082) 2026-04-01 18:39:46 +02:00
Niracler
6cf264dc18 Mark entity-disabled-by-default as exempt in sunricher_dali (#166861) 2026-04-01 18:17:24 +02:00
Mike O'Driscoll
d50d6db1bd Add battery sensors to Casper Glow (#166801) 2026-04-01 18:15:38 +02:00
g4bri3lDev
d680c72c7c Add sensor platform for OpenDisplay (#164998)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-01 18:01:26 +02:00
Zoltán Farkasdi
49a8c73f72 netatmo: NDB test addition and camera fix (#165375) 2026-04-01 17:47:33 +02:00
Brett Adams
dc00fcaf60 Fix Tesla Fleet OAuth scope refresh during reauth (#166920) 2026-04-01 17:47:12 +02:00
Artem Khvastunov
b056723b98 Add multi-plane support for Forecast.Solar integration (#160058)
Co-authored-by: Junie <noreply@jb.gg>
Co-authored-by: Junie <junie@jetbrains.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 17:42:17 +02:00
Franck Nijhof
0e5fc44af3 2026.4.0 (#166513) 2026-04-01 14:32:20 +02:00
Franck Nijhof
803531125b Bump version to 2026.4.0 2026-04-01 12:05:57 +00:00
Simone Chemelli
c70ddd559b Bump aioamazondevices to 13.3.2 (#167052) 2026-04-01 11:56:57 +00:00
Franck Nijhof
c06d898b00 Bump version to 2026.4.0b10 2026-04-01 10:23:39 +00:00
Bram Kragten
c6233d02e8 Update frontend to 20260325.5 (#167050) 2026-04-01 10:23:27 +00:00
Stefan Agner
37e69cad16 Store received backup in temp backup dir only (#166982) 2026-04-01 09:12:28 +00:00
Franck Nijhof
b14e729b2d Bump version to 2026.4.0b9 2026-04-01 06:35:41 +00:00
TheJulianJES
87e0f2d36c Bump ZHA to 1.1.1 (#167025) 2026-04-01 06:35:30 +00:00
J. Nick Koston
ae60135a08 Bump aiohttp to 3.13.5 (#167015) 2026-04-01 06:35:29 +00:00
Marc Mueller
3ed2dccbec Update requests to 2.33.1 (#167014) 2026-04-01 06:35:28 +00:00
Jackson_57
689ee7c1e7 Bump led-ble to 1.1.8 (#166999) 2026-04-01 06:35:26 +00:00
Joost Lekkerkerker
12d6d7ef88 Add BEGA brand (#166992) 2026-04-01 06:35:25 +00:00
dontinelli
4f88c5ed29 Bump solarlog_cli to 0.7.1 (#166990) 2026-04-01 06:35:24 +00:00
Joost Lekkerkerker
35826dfd14 Pull out Dropbox integration (#166986) 2026-04-01 06:35:22 +00:00
Ariel Ebersberger
12dc33eabc Add skeleton with repair issue to bmw integration (#166983)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-01 06:35:21 +00:00
Joost Lekkerkerker
9650aea6a1 Make sure we can fetch player stats in Chess.com (#166980) 2026-04-01 06:35:19 +00:00
Norbert Rittel
aaff319e70 Fix grammar of input_shutdown_failure error in victron_ble (#166972) 2026-04-01 06:35:18 +00:00
Bram Kragten
d9babc37f0 Bump version to 2026.4.0b8 2026-03-31 20:00:43 +02:00
Bram Kragten
a616de7452 Update frontend to 20260325.4 (#166970) 2026-03-31 20:00:23 +02:00
Erik Montnemery
817d3e1178 Remove redundant field descriptions from triggers and conditions (#166955) 2026-03-31 20:00:21 +02:00
Abílio Costa
e353ed1e2e Add counter purpose-specific condition (#166879) 2026-03-31 20:00:21 +02:00
Erik Montnemery
96b7210bca Add calendar conditions (#166643) 2026-03-31 20:00:19 +02:00
Erik Montnemery
22a6968a08 Add timer conditions (#166641)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-31 20:00:19 +02:00
Erik Montnemery
ce8519c1b1 Update hassfest conditions, services and triggers plugins to not require field descriptions (#166954)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 19:39:03 +02:00
Erik Montnemery
871d9ee0b4 Remove calendar and todo from unconditionally loaded integrations (#166951)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-03-31 19:39:02 +02:00
Paul Bottein
11d9f236b9 Fix "Shutdown" grammar in Roborock strings (#166948)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:39:01 +02:00
Artur Pragacz
8be6f441dd Register condition platform upon use (#166939) 2026-03-31 19:32:20 +02:00
Manu
d432092296 Fix StopIteration error in ista EcoTrend coordinator (#166929) 2026-03-31 19:32:19 +02:00
Branden Cash
4d168023a2 Bump srpenergy to 1.3.8 (#166926) 2026-03-31 19:32:17 +02:00
Artur Pragacz
d4d639dfa2 Register trigger platform upon use (#166911) 2026-03-31 19:32:15 +02:00
Erik Montnemery
92375078c0 Make field description optional for non config flows (#166892) 2026-03-31 19:32:14 +02:00
Andreas Jakl
fc6efac559 Prevent invalid phase count state in nrgkick (#166575) 2026-03-31 19:32:13 +02:00
Franck Nijhof
a9e1bbd5ab Improve time action naming consistency (#166532) 2026-03-31 19:32:11 +02:00
Franck Nijhof
dcf6416ae9 Improve datetime action naming consistency (#166530) 2026-03-31 19:32:10 +02:00
Franck Nijhof
df6b2ba0cd Improve date action naming consistency (#166529) 2026-03-31 19:32:10 +02:00
Franck Nijhof
19166e7938 Bump version to 2026.4.0b7 2026-03-31 08:25:00 +00:00
Robert Resch
3472a2bfbf Use async download for translations (#166940) 2026-03-31 08:24:51 +00:00
Franck Nijhof
8ac66e888e Bump version to 2026.4.0b6 2026-03-31 07:37:18 +00:00
Manu
39f2e89c4b Bump aiontfy to 0.8.4 (#166917) 2026-03-31 07:36:13 +00:00
Brett Adams
fa0ea041ad Fix Tesla Fleet startup scopes after OAuth refresh (#166922) 2026-03-31 07:34:18 +00:00
Manu
46b1981b77 Bump aiontfy to 0.8.3 (#166770) 2026-03-31 07:34:17 +00:00
Michael
29980d69b5 Add valve.opened and valve.closed triggers (#165160) 2026-03-31 07:29:21 +00:00
Raj Laud
3a81eb9552 Bump victron-ble-ha-parser (#166906) 2026-03-31 07:26:46 +00:00
Artur Pragacz
06e8333eab Unprefix entity name for entity ID generation (#166900) 2026-03-31 07:26:44 +00:00
Artur Pragacz
8ee0b97e5f Unprefix entity name for template function (#166899) 2026-03-31 07:26:43 +00:00
Joost Lekkerkerker
414756edc4 Get list of analytics insights integrations from next environment (#166867) 2026-03-31 07:26:42 +00:00
Michal Čihař
1355958f53 Skip unavailable sensors in LaCrosse View (#166859) 2026-03-31 07:26:40 +00:00
Lorenzo Gasparini
425d380d03 Bump fing_agent_api to 1.1.0 (#166855) 2026-03-31 07:26:39 +00:00
Denis Shulyaka
ff08335890 Fix OpenAI image generation with reasoning (#166827) 2026-03-31 07:26:37 +00:00
Florian
7170e3b232 Clamp surepetcare battery percentage to 0-100 (#166824)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 07:26:36 +00:00
Taylor Wilsdon
6111eaa9e9 Support vacation mode in Econet (#166659) 2026-03-31 07:26:34 +00:00
AlCalzone
e02a9fe61e Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 07:26:33 +00:00
Erik Montnemery
cba9bf5dc4 Add valve conditions (#166634) 2026-03-31 07:26:31 +00:00
Franck Nijhof
72a661f1fa Improve text action naming consistency (#166523)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-31 07:26:30 +00:00
Franck Nijhof
4168000155 Bump version to 2026.4.0b5 2026-03-30 08:56:27 +00:00
Manu
9d230b4f7c Bump habiticalib to 0.4.7 (#166772) 2026-03-30 08:56:21 +00:00
Matthias Alphart
745f32faa3 Update knx-frontend to 2026.3.28.223133 (#166764) 2026-03-30 08:56:20 +00:00
Jan Bouwhuis
112ad886c6 Revert mqtt vacuum segments support (#166761) 2026-03-30 08:56:19 +00:00
J. Nick Koston
8b0ec21a15 Bump aiohttp to 3.13.4 (#166756) 2026-03-30 08:56:18 +00:00
David Knowles
afce52a0f4 Bump pydrawise to 2026.3.0 (#166750) 2026-03-30 08:56:17 +00:00
Michael
7e4757c213 Bump aioimmich to 0.12.1 (#166746) 2026-03-30 08:56:16 +00:00
Louis Christ
d6dbcc8d82 Bump pyblu to 2.0.6 (#166738) 2026-03-30 08:56:15 +00:00
Åke Strandberg
fca87a2b8a Add missing code for miele washing machine (#166731) 2026-03-30 08:56:13 +00:00
Noah Husby
87e648b8b8 Bump aiorussound to 4.9.1 (#166718) 2026-03-30 08:56:12 +00:00
Will Moss
ada549489c Handle Oauth2 ImplementationUnavailableError in google_tasks (#166657)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:11 +00:00
Will Moss
15e13de2a6 Handle Oauth2 ImplementationUnavailableError in lyric (#166655)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:10 +00:00
Will Moss
dd74665622 Handle Oauth2 ImplementationUnavailableError in microbees (#166654)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:08 +00:00
Will Moss
ff8fc56696 Handle Oauth2 ImplementationUnavailableError in monzo (#166653)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:07 +00:00
Will Moss
2d8c903533 Handle Oauth2 ImplementationUnavailableError in iotty (#166652)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:06 +00:00
Will Moss
c1606f515b Handle Oauth2 ImplementationUnavailableError in google_sheets (#166651)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:05 +00:00
Will Moss
fac2702063 Handle Oauth2 ImplementationUnavailableError in google_mail (#166650)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:03 +00:00
Will Moss
76ae6958ed Handle Oauth2 ImplementationUnavailableError in google_assistant_sdk (#166649)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:02 +00:00
Will Moss
1876ed7d16 Handle Oauth2 ImplementationUnavailableError in geocaching (#166648)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:01 +00:00
Will Moss
08ef4e0de0 Handle Oauth2 ImplementationUnavailableError in gentex_homelink (#166646)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:56:00 +00:00
crash0verride11
a48db9d817 Correct Musiccast sound mode name (#166644)
Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com>
Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 08:55:59 +00:00
Will Moss
1334531740 Handle Oauth2 ImplementationUnavailableError in husqvarna_automower (#166633)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 08:55:58 +00:00
Erwin Douna
d769b16ada Add new OAuth exceptions to Neato (#166584)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 08:55:57 +00:00
Bram Kragten
c830320730 Bump version to 2026.4.0b4 2026-03-27 22:46:53 +01:00
Paul Bottein
336aa0f5df Update frontend to 20260325.2 (#166717) 2026-03-27 22:46:49 +01:00
Artur Pragacz
754291b34f Use legacy naming for entities (#166696) 2026-03-27 22:46:49 +01:00
Åke Strandberg
bbae0862b0 Add missing miele oven codes (#166690) 2026-03-27 22:46:48 +01:00
Åke Strandberg
6b7693b2fd Add missing miele program_id code (#166685) 2026-03-27 22:46:47 +01:00
Simone Chemelli
954926a05c Bump aioamazondevices to 13.3.1 (#166658) 2026-03-27 22:46:46 +01:00
Abílio Costa
71981f66ec Update idasen-ha to 2.6.5 (#166645) 2026-03-27 22:46:45 +01:00
Artur Pragacz
7f94f95ac9 Wait for device registry in entity registry loading (#166636) 2026-03-27 22:46:44 +01:00
Erik Montnemery
4ee3177c5d Add select conditions (#166612) 2026-03-27 22:46:43 +01:00
Erik Montnemery
9c1f9ca5c6 Add weather support to humidity conditions (#166599) 2026-03-27 22:46:42 +01:00
Franck Nijhof
cff4cf4d2c Bump version to 2026.4.0b3 2026-03-26 19:51:36 +00:00
Erik Montnemery
ee9d9781ee Add climate.is_hvac_mode condition (#166570) 2026-03-26 19:51:07 +00:00
Jamie Magee
1b972d4adc Remove tplink_lte integration (#166615) 2026-03-26 19:49:52 +00:00
Bram Kragten
72598479d5 Update frontend to 20260325.1 (#166614) 2026-03-26 19:49:50 +00:00
Erik Montnemery
02599a4a6e Add condition humidifier.is_mode (#166610) 2026-03-26 19:49:49 +00:00
Erik Montnemery
af9f351fce Restore support for number entities as limits in moisture conditions and triggers (#166608) 2026-03-26 19:49:47 +00:00
Erik Montnemery
ff79943776 Restore support for number entities as limits in battery conditions and triggers (#166607) 2026-03-26 19:49:46 +00:00
Erik Montnemery
e60048ef30 Add input_boolean support to switch conditions (#166602) 2026-03-26 19:49:45 +00:00
Erik Montnemery
24c0b22038 Add light.is_brightness condition (#166601) 2026-03-26 19:49:43 +00:00
Norbert Rittel
6f32a53742 Make siren conditions consistent with new wording (#166600) 2026-03-26 19:49:42 +00:00
Erik Montnemery
da9d1080d9 Remove number entity support from power triggers and conditions (#166597) 2026-03-26 19:49:41 +00:00
Erik Montnemery
2ea4d7913e Remove number entity support from moisture triggers and conditions (#166596) 2026-03-26 19:49:40 +00:00
Erik Montnemery
16999e3707 Remove number entity support from illuminance triggers and conditions (#166595) 2026-03-26 19:49:38 +00:00
Erik Montnemery
5c53b847dc Remove number entity support from humidity triggers and conditions (#166594) 2026-03-26 19:49:37 +00:00
Erik Montnemery
3afd763d16 Remove number entity support from battery triggers and conditions (#166593) 2026-03-26 19:49:35 +00:00
Abílio Costa
75a15ed24e Add todo to experimental triggers (#166591) 2026-03-26 19:49:34 +00:00
Ronald van der Meer
6d56597a2a Bump pooldose 0.9.0 (#166589) 2026-03-26 19:49:32 +00:00
Erik Montnemery
5872222213 Remove class NumericalDomainSpec (#166588) 2026-03-26 19:49:31 +00:00
reneboer
bd5c73fd7b Bump renault-api to 0.5.7 (#166586) 2026-03-26 19:49:30 +00:00
hanwg
d8a32dcf69 Add missing translations for Telegram bot (#166581)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-26 19:49:29 +00:00
Devin Slick
87cd90ab5d Bump lojack-api to 0.7.2 (#166560)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:45:06 +00:00
Tom
cb5b0c5b5e Verify Proxmox permissions when creating snapshots (#166547) 2026-03-26 19:45:04 +00:00
John Meyers
2fa16101f4 Update rainmachine solar radiation to reflect it is per day, not per … (#166040) 2026-03-26 19:45:03 +00:00
Franck Nijhof
6dd5c30b49 Bump version to 2026.4.0b2 2026-03-26 10:59:11 +00:00
AlCalzone
72f5a572eb Revert: Create repair issue for legacy Z-Wave Door state sensors that are still in use (#166583) 2026-03-26 10:58:55 +00:00
Erik Montnemery
d501d8cb28 Adjust some trigger and condition schemas (#166568) 2026-03-26 10:58:54 +00:00
Keilin Bickar
35c4b4ff5b Bump asyncsleepiq to 1.7.1 (#166552) 2026-03-26 10:58:53 +00:00
Keilin Bickar
f3e8ac5b8e Bump sense-energy to 0.14.0 (#166550) 2026-03-26 10:58:51 +00:00
tronikos
ab2bcd84c6 Add Google Drive backup upload progress (#166549) 2026-03-26 10:58:50 +00:00
Ariel Ebersberger
cdf7b013a9 Add battery triggers (#166258) 2026-03-26 10:58:48 +00:00
Erik Montnemery
eeba0467a1 Add trigger humidifier.mode_changed (#166241)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-26 10:58:47 +00:00
Franck Nijhof
43ca72bf7e Bump version to 2026.4.0b1 2026-03-26 00:01:26 +00:00
Franck Nijhof
aa9e279026 Improve conversation action naming consistency (#166542) 2026-03-26 00:01:16 +00:00
Franck Nijhof
9f3917830d Improve weather action naming consistency (#166540) 2026-03-26 00:01:15 +00:00
Franck Nijhof
c458bc2ee3 Improve dashboard action naming consistency (#166539) 2026-03-26 00:01:14 +00:00
Franck Nijhof
e0455629d7 Improve logger action naming consistency (#166538) 2026-03-26 00:01:12 +00:00
Franck Nijhof
b802dcba8d Improve group action naming consistency (#166537) 2026-03-26 00:01:11 +00:00
Franck Nijhof
7ff868e94c Improve water heater action naming consistency (#166535)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-26 00:01:10 +00:00
Franck Nijhof
44bd3e3d74 Improve device tracker action naming consistency (#166534) 2026-03-26 00:01:09 +00:00
Jordan Harvey
9d793ce1df Bump pyanglianwater to 3.1.2 (#166531) 2026-03-26 00:01:07 +00:00
Franck Nijhof
d8dee8fc91 Improve image action naming consistency (#166527) 2026-03-26 00:01:06 +00:00
Franck Nijhof
3c52acb825 Improve counter action naming consistency (#166526) 2026-03-26 00:01:04 +00:00
Franck Nijhof
cb195be6ad Improve automation action naming consistency (#166525) 2026-03-26 00:01:03 +00:00
Franck Nijhof
08f7bed679 Improve humidifier action naming consistency (#166524) 2026-03-26 00:01:02 +00:00
Erik Montnemery
744563c7a7 Speed up trigger tests (#166522) 2026-03-26 00:01:01 +00:00
Franck Nijhof
5d48801645 Improve valve action naming consistency (#166521) 2026-03-26 00:00:59 +00:00
Franck Nijhof
4211686c07 Improve script action naming consistency (#166517) 2026-03-26 00:00:58 +00:00
Franck Nijhof
98379c9642 Improve cloud action naming consistency (#166516) 2026-03-26 00:00:57 +00:00
Erik Montnemery
a3c9d35a13 Use NumericThresholdSelector in numeric conditions (#166507) 2026-03-26 00:00:56 +00:00
Erik Montnemery
5a7abc0a92 Add trigger water_heater.operation_mode_changed (#166450) 2026-03-26 00:00:54 +00:00
johanzander
ade73ec159 growatt_server: use human-readable labels in exception messages (#166024)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-26 00:00:53 +00:00
Franck Nijhof
6f7a5d9320 Bump version to 2026.4.0b0 2026-03-25 18:48:08 +00:00
448 changed files with 20789 additions and 15558 deletions

View File

@@ -1,6 +1,7 @@
---
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.
description: Reviews GitHub pull requests and provides feedback comments.
disallowedTools: Write, Edit
---
# Review GitHub Pull Request

View File

@@ -195,6 +195,7 @@ GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_R
# Create PR (gh pr create pushes the branch automatically)
gh pr create --repo home-assistant/core --base dev \
--head "$GITHUB_USER:$BRANCH" \
--draft \
--title "TITLE_HERE" \
--body "$(cat <<'EOF'
BODY_HERE

View File

@@ -3,54 +3,27 @@ name: Home Assistant Integration knowledge
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
---
### File Locations
## File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## Integration Templates
## General guidelines
### Standard Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator (if needed)
├── entity.py # Base entity class (if shared patterns)
├── sensor.py # Sensor platform
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
### Minimal Integration Checklist
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
- [ ] `config_flow.py` with UI configuration support
- [ ] `const.py` with `DOMAIN` constant
- [ ] `strings.json` with at least config flow text
- [ ] Platform files (`sensor.py`, etc.) as needed
- [ ] `quality_scale.yaml` with rule status tracking
## Integration Quality Scale
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
### Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality
- **Gold**: Advanced features
- **Platinum**: Highest quality standards
### Quality Scale Progression
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
- **Silver → Gold**: Add device management, diagnostics, translations
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
@@ -61,726 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
### Example `quality_scale.yaml` Structure
```yaml
rules:
# Bronze (mandatory)
config-flow: done
entity-unique-id: done
action-setup:
status: exempt
comment: Integration does not register custom actions.
# Silver (if targeting Silver+)
entity-unavailable: done
parallel-updates: done
# Gold (if targeting Gold+)
devices: done
diagnostics: done
# Platinum (if targeting Platinum)
strict-typing: done
```
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
## Code Organization
### Core Locations
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
- Integration structure:
- `homeassistant/components/{domain}/const.py` - Constants
- `homeassistant/components/{domain}/models.py` - Data models
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
### Common Modules
- **coordinator.py**: Centralize data fetching logic
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
- **entity.py**: Base entity definitions to reduce duplication
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
_attr_has_entity_name = True
```
### Runtime Data Storage
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
client = MyClient(entry.data[CONF_HOST])
entry.runtime_data = client
```
### Manifest Requirements
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
### Config Flow Patterns
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
- **Unique ID Management**:
```python
await self.async_set_unique_id(device_unique_id)
self._abort_if_unique_id_configured()
```
- **Error Handling**: Define errors in `strings.json` under `config.error`
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
### Integration Ownership
- **manifest.json**: Add GitHub usernames to `codeowners`:
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@me"]
}
```
### Async Dependencies (Platinum)
- **Requirement**: All dependencies must use asyncio
- Ensures efficient task handling without thread context switching
### WebSession Injection (Platinum)
- **Pass WebSession**: Support passing web sessions to dependencies
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Set up integration from config entry."""
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
```
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
### Data Update Coordinator
- **Standard Pattern**: Use for efficient data management
```python
class MyCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
self.client = client
async def _async_update_data(self):
try:
return await self.client.fetch_data()
except ApiError as err:
raise UpdateFailed(f"API communication error: {err}")
```
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
## Integration Guidelines
### Configuration Flow
- **UI Setup Required**: All integrations must support configuration via UI
- **Manifest**: Set `"config_flow": true` in `manifest.json`
- **Data Storage**:
- Connection-critical config: Store in `ConfigEntry.data`
- Non-critical settings: Store in `ConfigEntry.options`
- **Validation**: Always validate user input before creating entries
- **Config Entry Naming**:
- ❌ Do NOT allow users to set config entry names in config flows
- Names are automatically generated or can be customized later in UI
- ✅ Exception: Helper integrations MAY allow custom names in config flow
- **Connection Testing**: Test device/service connection during config flow:
```python
try:
await client.get_data()
except MyException:
errors["base"] = "cannot_connect"
```
- **Duplicate Prevention**: Prevent duplicate configurations:
```python
# Using unique ID
await self.async_set_unique_id(identifier)
self._abort_if_unique_id_configured()
# Using unique data
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
### Reauthentication Support
- **Required Method**: Implement `async_step_reauth` in config flow
- **Credential Updates**: Allow users to update credentials without re-adding
- **Validation**: Verify account matches existing unique ID:
```python
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
)
```
### Reconfiguration Flow
- **Purpose**: Allow configuration updates without removing device
- **Implementation**: Add `async_step_reconfigure` method
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
### Device Discovery
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
```json
{
"zeroconf": ["_mydevice._tcp.local."]
}
```
- **Discovery Handler**: Implement appropriate `async_step_*` method:
```python
async def async_step_zeroconf(self, discovery_info):
"""Handle zeroconf discovery."""
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
```
- **Network Updates**: Use discovery to update dynamic IP addresses
### Network Discovery Implementation
- **Zeroconf/mDNS**: Use async instances
```python
aiozc = await zeroconf.async_get_async_instance(hass)
```
- **SSDP Discovery**: Register callbacks with cleanup
```python
entry.async_on_unload(
ssdp.async_register_callback(
hass, _async_discovered_device,
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
)
)
```
### Bluetooth Integration
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
- **Connectable**: Set `"connectable": true` for connection-required devices
- **Scanner Usage**: Always use shared scanner instance
```python
scanner = bluetooth.async_get_scanner()
entry.async_on_unload(
bluetooth.async_register_callback(
hass, _async_discovered_device,
{"service_uuid": "example_uuid"},
bluetooth.BluetoothScanningMode.ACTIVE
)
)
```
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
### Setup Validation
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
- **Exception Handling**:
- `ConfigEntryNotReady`: Device offline or temporary failure
- `ConfigEntryAuthFailed`: Authentication issues
- `ConfigEntryError`: Unresolvable setup problems
### Config Entry Unloading
- **Required**: Implement `async_unload_entry` for runtime removal/reload
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
```python
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.listener() # Clean up resources
return unload_ok
```
### Service Actions
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
- **Validation**: Check config entry existence and loaded state:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def service_action(call: ServiceCall) -> ServiceResponse:
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
```
- **Exception Handling**: Raise appropriate exceptions:
```python
# For invalid input
if end_date < start_date:
raise ServiceValidationError("End date must be after start date")
# For service errors
try:
await client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError("Could not connect to the schedule") from err
```
### Service Registration Patterns
- **Entity Services**: Register on platform setup
```python
platform.async_register_entity_service(
"my_entity_service",
{vol.Required("parameter"): cv.string},
"handle_service_method"
)
```
- **Service Schema**: Always validate input
```python
SERVICE_SCHEMA = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
vol.Optional("timeout", default=30): cv.positive_int,
})
```
- **Services File**: Create `services.yaml` with descriptions and field definitions
### Polling
- Use update coordinator pattern when possible
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
- **Minimum Intervals**:
- Local network: 5 seconds
- Cloud services: 60 seconds
- **Parallel Updates**: Specify number of concurrent updates:
```python
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
# OR
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
```
## Entity Development
### Unique IDs
- **Required**: Every entity must have a unique ID for registry tracking
- Must be unique per platform (not per integration)
- Don't include integration domain or platform in ID
- **Implementation**:
```python
class MySensor(SensorEntity):
def __init__(self, device_id: str) -> None:
self._attr_unique_id = f"{device_id}_temperature"
```
**Acceptable ID Sources**:
- Device serial numbers
- MAC addresses (formatted using `format_mac` from device registry)
- Physical identifiers (printed/EEPROM)
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
**Never Use**:
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
### Entity Descriptions
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
- **Bad pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
)
```
- **Good pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```
### Entity Naming
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
- **For specific fields**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
def __init__(self, device: Device, field: str) -> None:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
)
self._attr_name = field # e.g., "temperature", "humidity"
```
- **For device itself**: Set `_attr_name = None`
### Event Lifecycle Management
- **Subscribe in `async_added_to_hass`**:
```python
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
self.async_on_remove(
self.client.events.subscribe("my_event", self._handle_event)
)
```
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
- Never subscribe in `__init__` or other methods
### State Handling
- Unknown values: Use `None` (not "unknown" or "unavailable")
- Availability: Implement `available()` property instead of using "unavailable" state
### Entity Availability
- **Mark Unavailable**: When data cannot be fetched from device/service
- **Coordinator Pattern**:
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.identifier in self.coordinator.data
```
- **Direct Update Pattern**:
```python
async def async_update(self) -> None:
"""Update entity."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = data.value
```
### Extra State Attributes
- All attribute keys must always be present
- Unknown values: Use `None`
- Provide descriptive attributes
## Device Management
### Device Registry
- **Create Devices**: Group related entities under devices
- **Device Info**: Provide comprehensive metadata:
```python
_attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac)},
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model="My Sensor",
sw_version=device.version,
)
```
- For services: Add `entry_type=DeviceEntryType.SERVICE`
### Dynamic Device Addition
- **Auto-detect New Devices**: After initial setup
- **Implementation Pattern**:
```python
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
entry.async_on_unload(coordinator.async_add_listener(_check_device))
```
### Stale Device Removal
- **Auto-remove**: When devices disappear from hub/account
- **Device Registry Update**:
```python
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
```
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
### Entity Categories
- **Required**: Assign appropriate category to entities
- **Implementation**: Set `_attr_entity_category`
```python
class MySensor(SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
```
- Categories include: `DIAGNOSTIC` for system/technical information
### Device Classes
- **Use When Available**: Set appropriate device class for entity type
```python
class MyTemperatureSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
```
- Provides context for: unit conversion, voice control, UI representation
### Disabled by Default
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
```python
class MySignalStrengthSensor(SensorEntity):
_attr_entity_registry_enabled_default = False
```
- Target: frequently changing states, technical diagnostics
### Entity Translations
- **Required with has_entity_name**: Support international users
- **Implementation**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
_attr_translation_key = "phase_voltage"
```
- Create `strings.json` with translations:
```json
{
"entity": {
"sensor": {
"phase_voltage": {
"name": "Phase voltage"
}
}
}
}
```
### Exception Translations (Gold)
- **Translatable Errors**: Use translation keys for user-facing exceptions
- **Implementation**:
```python
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
```
- Add to `strings.json`:
```json
{
"exceptions": {
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
}
}
}
```
### Icon Translations (Gold)
- **Dynamic Icons**: Support state and range-based icon selection
- **State-based Icons**:
```json
{
"entity": {
"sensor": {
"tree_pollen": {
"default": "mdi:tree",
"state": {
"high": "mdi:tree-outline"
}
}
}
}
}
```
- **Range-based Icons** (for numeric values):
```json
{
"entity": {
"sensor": {
"battery_level": {
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## Testing Requirements
- **Location**: `tests/components/{domain}/`
- **Coverage Requirement**: Above 95% test coverage for all modules
- **Best Practices**:
- Use pytest fixtures from `tests.common`
- Mock all external dependencies
- Use snapshots for complex data structures
- Follow existing test patterns
### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
- Reauthentication/reconfigure flows
### Testing
- **Integration-specific tests** (recommended):
```bash
pytest ./tests/components/<integration_domain> \
--cov=homeassistant.components.<integration_domain> \
--cov-report term-missing \
--durations-min=1 \
--durations=0 \
--numprocesses=auto
```
### Testing Best Practices
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
- **Use snapshot testing** - For verifying entity states and attributes
- **Test through integration setup** - Don't test entities in isolation
- **Mock external APIs** - Use fixtures with realistic JSON data
- **Verify registries** - Ensure entities are properly registered with devices
### Config Flow Testing Template
```python
async def test_user_flow_success(hass, mock_api):
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test form submission
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == TEST_USER_INPUT
async def test_flow_connection_error(hass, mock_api_error):
"""Test connection error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
```
### Entity Testing Patterns
```python
@pytest.fixture
def platforms() -> list[Platform]:
"""Overridden fixture to specify platforms to test."""
return [Platform.SENSOR] # Or another specific platform as needed.
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Ensure entities are correctly assigned to device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "device_unique_id")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
```
### Mock Patterns
```python
# Modern integration fixture setup
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Integration",
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
unique_id="device_unique_id",
)
@pytest.fixture
def mock_device_api() -> Generator[MagicMock]:
"""Return a mocked device API."""
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
api = api_mock.return_value
api.get_data.return_value = MyDeviceData.from_json(
load_fixture("device_data.json", DOMAIN)
)
yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return PLATFORMS
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Debugging & Troubleshooting
### Common Issues & Solutions
- **Integration won't load**: Check `manifest.json` syntax and required fields
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
- **Config flow errors**: Check `strings.json` entries and error handling
- **Discovery not working**: Verify manifest discovery configuration and callbacks
- **Tests failing**: Check mock setup and async context
### Debug Logging Setup
```python
# Enable debug logging in tests
caplog.set_level(logging.DEBUG, logger="my_integration")
# In integration code - use proper logging
_LOGGER = logging.getLogger(__name__)
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
```
### Validation Commands
```bash
# Check specific integration
python -m script.hassfest --integration-path homeassistant/components/my_integration
# Validate quality scale
# Check quality_scale.yaml against current rules
# Run integration tests with coverage
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations

View File

@@ -3,17 +3,4 @@
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
- **Required**: Implement diagnostic data collection
- **Implementation**:
```python
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": entry.runtime_data.data,
}
```
- **Security**: Never expose passwords, tokens, or sensitive coordinates

View File

@@ -8,29 +8,6 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
- Provide specific steps users need to take to resolve the issue
- Use friendly, helpful language
- Include relevant context (device names, error details, etc.)
- **Implementation**:
```python
ir.async_create_issue(
hass,
DOMAIN,
"outdated_version",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="outdated_version",
)
```
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
```json
{
"issues": {
"outdated_version": {
"title": "Device firmware is outdated",
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
}
}
}
```
- **String Content Must Include**:
- What the problem is
- Why it matters
@@ -41,15 +18,4 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve

View File

@@ -11,10 +11,9 @@
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Code Review Guidelines
## Git Commit Guidelines
**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
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Development Commands

View File

@@ -49,7 +49,7 @@ jobs:
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
uses: home-assistant/actions/helpers/info@5f5b077d63a1e4c53019231409a0c4d791fb74e5 # zizmor: ignore[unpinned-uses]
- name: Get version
id: version
@@ -342,7 +342,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.5.3"

View File

@@ -1392,7 +1392,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
fail_ci_if_error: true
flags: full-suite
@@ -1563,7 +1563,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1591,7 +1591,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
report_type: test_results
fail_ci_if_error: true

View File

@@ -87,6 +87,13 @@ repos:
language: script
types: [text]
files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
- id: gen_copilot_instructions
name: gen_copilot_instructions
entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions
pass_filenames: false
language: script
types: [text]
files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$
- id: hassfest
name: hassfest
entry: script/run-in-env.sh python3 -m script.hassfest

View File

@@ -174,6 +174,7 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*

View File

@@ -2,10 +2,9 @@
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Code Review Guidelines
## Git Commit Guidelines
**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
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Development Commands

11
CODEOWNERS generated
View File

@@ -37,6 +37,13 @@ build.json @home-assistant/supervisor
# Other code
/homeassistant/scripts/check_config.py @kellerza
# Agent Configurations
AGENTS.md @home-assistant/core
CLAUDE.md @home-assistant/core
/.agent/ @home-assistant/core
/.claude/ @home-assistant/core
/.gemini/ @home-assistant/core
# Integrations
/homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86
@@ -401,6 +408,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dropbox/ @bdr99
/tests/components/dropbox/ @bdr99
/homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221
@@ -1299,6 +1308,8 @@ build.json @home-assistant/supervisor
/tests/components/pi_hole/ @shenxn
/homeassistant/components/picnic/ @corneyl @codesalatdev
/tests/components/picnic/ @corneyl @codesalatdev
/homeassistant/components/picotts/ @rooggiieerr
/tests/components/picotts/ @rooggiieerr
/homeassistant/components/ping/ @jpbede
/tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan

View File

@@ -36,7 +36,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_connection_error",
) from err
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:

View File

@@ -64,7 +64,7 @@ rules:
status: exempt
comment: Not required for this integration at this stage.
entity-translations: todo
exception-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:

View File

@@ -55,6 +55,9 @@
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"setup_connection_error": {
"message": "Failed to connect to the Actron Air API"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}

View File

@@ -74,7 +74,8 @@ async def _resolve_attachments(
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image_data.content_type,
mime_type=attachment.get("media_content_type")
or image_data.content_type,
path=temp_filename,
)
)
@@ -89,7 +90,7 @@ async def _resolve_attachments(
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=media.mime_type,
mime_type=attachment.get("media_content_type") or media.mime_type,
path=media.path,
)
)

View File

@@ -33,14 +33,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
AirOSFirmwareUpdateCoordinator,
AirOSRuntimeData,
)
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.UPDATE,
]
_LOGGER = logging.getLogger(__name__)
@@ -86,10 +93,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
airos_device = airos_class(**conn_data)
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
await coordinator.async_config_entry_first_refresh()
data_coordinator = AirOSDataUpdateCoordinator(
hass, entry, device_data, airos_device
)
await data_coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None
if device_data["fw_major"] >= 8:
firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device)
await firmware_coordinator.async_config_entry_first_refresh()
entry.runtime_data = AirOSRuntimeData(
status=data_coordinator,
firmware=firmware_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)

View File

@@ -87,7 +87,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
entities = [
AirOSBinarySensor(coordinator, description)

View File

@@ -31,7 +31,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS button from a config entry."""
async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)])
async_add_entities(
[AirOSRebootButton(config_entry.runtime_data.status, REBOOT_BUTTON)]
)
class AirOSRebootButton(AirOSEntity, ButtonEntity):

View File

@@ -5,6 +5,7 @@ from datetime import timedelta
DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1)
UPDATE_SCAN_INTERVAL = timedelta(days=1)
MANUFACTURER = "Ubiquiti"

View File

@@ -2,7 +2,10 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any, TypeVar
from airos.airos6 import AirOS6, AirOS6Data
from airos.airos8 import AirOS8, AirOS8Data
@@ -19,20 +22,61 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
from .const import DOMAIN, SCAN_INTERVAL, UPDATE_SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
AirOSDeviceDetect = AirOS8 | AirOS6
AirOSDataDetect = AirOS8Data | AirOS6Data
type AirOSDeviceDetect = AirOS8 | AirOS6
type AirOSDataDetect = AirOS8Data | AirOS6Data
type AirOSUpdateData = dict[str, Any]
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
type AirOSConfigEntry = ConfigEntry[AirOSRuntimeData]
T = TypeVar("T", bound=AirOSDataDetect | AirOSUpdateData)
@dataclass
class AirOSRuntimeData:
"""Data for AirOS config entry."""
status: AirOSDataUpdateCoordinator
firmware: AirOSFirmwareUpdateCoordinator | None
async def async_fetch_airos_data(
airos_device: AirOSDeviceDetect,
update_method: Callable[[], Awaitable[T]],
) -> T:
"""Fetch data from AirOS device."""
try:
await airos_device.login()
return await update_method()
except AirOSConnectionAuthenticationError as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
_LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except AirOSDataMissingError as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_data_missing",
) from err
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
"""Class to manage fetching AirOS data from single endpoint."""
"""Class to manage fetching AirOS status data from single endpoint."""
airos_device: AirOSDeviceDetect
config_entry: AirOSConfigEntry
def __init__(
@@ -54,28 +98,33 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
)
async def _async_update_data(self) -> AirOSDataDetect:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
return await self.airos_device.status()
except AirOSConnectionAuthenticationError as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
_LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except AirOSDataMissingError as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_data_missing",
) from err
"""Fetch status data from AirOS."""
return await async_fetch_airos_data(self.airos_device, self.airos_device.status)
class AirOSFirmwareUpdateCoordinator(DataUpdateCoordinator[AirOSUpdateData]):
"""Class to manage fetching AirOS firmware."""
config_entry: AirOSConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
airos_device: AirOSDeviceDetect,
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOSUpdateData:
"""Fetch firmware data from AirOS."""
return await async_fetch_airos_data(
self.airos_device, self.airos_device.update_check
)

View File

@@ -29,5 +29,15 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
"data": {
"status_data": async_redact_data(
entry.runtime_data.status.data.to_dict(), TO_REDACT_AIROS
),
"firmware_data": async_redact_data(
entry.runtime_data.firmware.data
if entry.runtime_data.firmware is not None
else {},
TO_REDACT_AIROS,
),
},
}

View File

@@ -180,7 +180,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS]

View File

@@ -206,6 +206,12 @@
},
"reboot_failed": {
"message": "The device did not accept the reboot request. Try again, or check your device web interface for errors."
},
"update_connection_authentication_error": {
"message": "Authentication or connection failed during firmware update"
},
"update_error": {
"message": "Connection failed during firmware update"
}
}
}

View File

@@ -0,0 +1,101 @@
"""AirOS update component for Home Assistant."""
from __future__ import annotations
import logging
from typing import Any
from airos.exceptions import AirOSConnectionAuthenticationError, AirOSException
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
AirOSFirmwareUpdateCoordinator,
)
from .entity import AirOSEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS update entity from a config entry."""
runtime_data = config_entry.runtime_data
if runtime_data.firmware is None: # Unsupported device
return
async_add_entities([AirOSUpdateEntity(runtime_data.status, runtime_data.firmware)])
class AirOSUpdateEntity(AirOSEntity, UpdateEntity):
"""Update entity for AirOS firmware updates."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(
self,
status: AirOSDataUpdateCoordinator,
firmware: AirOSFirmwareUpdateCoordinator,
) -> None:
"""Initialize the AirOS update entity."""
super().__init__(status)
self.status = status
self.firmware = firmware
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update"
@property
def installed_version(self) -> str | None:
"""Return the installed firmware version."""
return self.status.data.host.fwversion
@property
def latest_version(self) -> str | None:
"""Return the latest firmware version."""
if not self.firmware.data.get("update", False):
return self.status.data.host.fwversion
return self.firmware.data.get("version")
@property
def release_url(self) -> str | None:
"""Return the release url of the latest firmware."""
return self.firmware.data.get("changelog")
async def async_install(
self,
version: str | None,
backup: bool,
**kwargs: Any,
) -> None:
"""Handle the firmware update installation."""
_LOGGER.debug("Starting firmware update")
try:
await self.status.airos_device.login()
await self.status.airos_device.download()
await self.status.airos_device.install()
except AirOSConnectionAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_connection_authentication_error",
) from err
except AirOSException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_error",
) from err

View File

@@ -2,19 +2,15 @@
from __future__ import annotations
import anthropic
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -24,12 +20,11 @@ from .const import (
DOMAIN,
LOGGER,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Anthropic."""
@@ -39,29 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Set up Anthropic from a config entry."""
client = anthropic.AsyncAnthropic(
api_key=entry.data[CONF_API_KEY], http_client=get_async_client(hass)
)
try:
await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
entry.runtime_data = client
coordinator = AnthropicCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -0,0 +1,78 @@
"""Coordinator for the Anthropic integration."""
from __future__ import annotations
from datetime import timedelta
import anthropic
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
class AnthropicCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=UPDATE_INTERVAL_CONNECTED,
update_method=self.async_update_data,
always_update=False,
)
self.client = anthropic.AsyncAnthropic(
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
)
@callback
def async_set_updated_data(self, data: None) -> None:
"""Manually update data, notify listeners and update refresh interval."""
self.update_interval = UPDATE_INTERVAL_CONNECTED
super().async_set_updated_data(data)
async def async_update_data(self) -> None:
"""Fetch data from the API."""
try:
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
await self.client.models.list(timeout=10.0)
self.update_interval = UPDATE_INTERVAL_CONNECTED
except anthropic.APITimeoutError as err:
raise TimeoutError(err.message or str(err)) from err
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.APIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
def mark_connection_error(self) -> None:
"""Mark the connection as having an error and reschedule background check."""
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
if self.last_update_success:
self.last_update_success = False
self.async_update_listeners()
if self._listeners and not self.hass.is_stopping:
self._schedule_refresh()

View File

@@ -82,12 +82,11 @@ from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.json import JsonObjectType
from . import AnthropicConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
@@ -111,6 +110,7 @@ from .const import (
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
@@ -658,7 +658,7 @@ def _create_token_stats(
}
class AnthropicBaseLLMEntity(Entity):
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"""Anthropic base LLM entity."""
_attr_has_entity_name = True
@@ -666,6 +666,7 @@ class AnthropicBaseLLMEntity(Entity):
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
super().__init__(entry.runtime_data)
self.entry = entry
self.subentry = subentry
self._attr_unique_id = subentry.subentry_id
@@ -877,7 +878,8 @@ class AnthropicBaseLLMEntity(Entity):
if tools:
model_args["tools"] = tools
client = self.entry.runtime_data
coordinator = self.entry.runtime_data
client = coordinator.client
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(max_iterations):
@@ -899,13 +901,24 @@ class AnthropicBaseLLMEntity(Entity):
)
messages.extend(new_messages)
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.APIConnectionError as err:
LOGGER.info("Connection error while talking to Anthropic: %s", err)
coordinator.mark_connection_error()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
# Non-connection error, mark connection as healthy
coordinator.async_set_updated_data(None)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
@@ -917,6 +930,7 @@ class AnthropicBaseLLMEntity(Entity):
) from err
if not chat_log.unresponded_tool_results:
coordinator.async_set_updated_data(None)
break

View File

@@ -35,9 +35,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |

View File

@@ -58,7 +58,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
if entry.entry_id in self._model_list_cache:
model_list = self._model_list_cache[entry.entry_id]
else:
client = entry.runtime_data
client = entry.runtime_data.client
model_list = [
model_option
for model_option in await get_model_list(client)

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.8.2"],
"requirements": ["arcam-fmj==1.8.3"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -91,6 +91,7 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
value_fn=lambda state: (
vp.colorspace.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
and vp.colorspace is not None
else None
),
),

View File

@@ -75,7 +75,7 @@
},
"services": {
"announce": {
"description": "Lets a satellite announce a message.",
"description": "Lets an Assist satellite announce a message.",
"fields": {
"media_id": {
"description": "The media ID to announce instead of using text-to-speech.",
@@ -94,10 +94,10 @@
"name": "Preannounce media ID"
}
},
"name": "Announce"
"name": "Announce on satellite"
},
"ask_question": {
"description": "Asks a question and gets the user's response.",
"description": "Lets an Assist satellite ask a question and get the user's response.",
"fields": {
"answers": {
"description": "Possible answers to the question.",
@@ -124,10 +124,10 @@
"name": "Question media ID"
}
},
"name": "Ask question"
"name": "Ask question on satellite"
},
"start_conversation": {
"description": "Starts a conversation from a satellite.",
"description": "Starts a conversation from an Assist satellite.",
"fields": {
"extra_system_prompt": {
"description": "Provide background information to the AI about the request.",
@@ -150,13 +150,13 @@
"name": "Message"
}
},
"name": "Start conversation"
"name": "Start conversation on satellite"
}
},
"title": "Assist satellite",
"triggers": {
"idle": {
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
"description": "Triggers after one or more Assist satellites become idle after having processed a command.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -165,7 +165,7 @@
"name": "Satellite became idle"
},
"listening": {
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
"description": "Triggers after one or more Assist satellites start listening for a command from someone.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -174,7 +174,7 @@
"name": "Satellite started listening"
},
"processing": {
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
"description": "Triggers after one or more Assist satellites start processing a command after having heard it.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -183,7 +183,7 @@
"name": "Satellite started processing"
},
"responding": {
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
"description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"

View File

@@ -54,7 +54,7 @@
"message": "Storage account {account_name} not found"
},
"cannot_connect": {
"message": "Can not connect to storage account {account_name}"
"message": "Cannot connect to storage account {account_name}"
},
"container_not_found": {
"message": "Storage container {container_name} not found"

View File

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

View File

@@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
Platform.SENSOR,
]

View File

@@ -4,7 +4,11 @@ from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -21,7 +25,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform for Casper Glow."""
async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)])
async_add_entities(
[
CasperGlowPausedBinarySensor(entry.runtime_data),
CasperGlowChargingBinarySensor(entry.runtime_data),
]
)
class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
@@ -46,6 +55,34 @@ class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.is_paused is not None:
if state.is_paused is not None and state.is_paused != self._attr_is_on:
self._attr_is_on = state.is_paused
self.async_write_ha_state()
self.async_write_ha_state()
class CasperGlowChargingBinarySensor(CasperGlowEntity, BinarySensorEntity):
"""Binary sensor indicating whether the Casper Glow is charging."""
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the charging binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_charging"
if coordinator.device.state.is_charging is not None:
self._attr_is_on = coordinator.device.state.is_charging
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.is_charging is not None and state.is_charging != self._attr_is_on:
self._attr_is_on = state.is_charging
self.async_write_ha_state()

View File

@@ -53,15 +53,15 @@ rules:
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: No applicable device classes for binary_sensor, button, light, or select entities.
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
repair-issues:
status: exempt
comment: Integration does not register repair issues.
stale-devices: todo
# Platinum

View File

@@ -0,0 +1,61 @@
"""Casper Glow integration sensor platform."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform for Casper Glow."""
async_add_entities([CasperGlowBatterySensor(entry.runtime_data)])
class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
"""Sensor entity for Casper Glow battery level."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the battery sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_battery"
if coordinator.device.state.battery_level is not None:
self._attr_native_value = coordinator.device.state.battery_level.percentage
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.battery_level is not None:
new_value = state.battery_level.percentage
if new_value != self._attr_native_value:
self._attr_native_value = new_value
self.async_write_ha_state()

View File

@@ -239,7 +239,7 @@
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
},
"low_temp_higher_than_high_temp": {
"message": "'Lower target temperature' can not be higher than 'Upper target temperature'."
"message": "'Lower target temperature' cannot be higher than 'Upper target temperature'."
},
"missing_target_temperature_entity_feature": {
"message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it."

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.1"]
"requirements": ["aiocomelit==2.0.2"]
}

View File

@@ -21,6 +21,7 @@ from .const import ( # noqa: F401
ATTR_DEV_ID,
ATTR_GPS,
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_LOCATION_NAME,
ATTR_MAC,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from typing import final
from typing import Any, final
from propcache.api import cached_property
@@ -18,7 +18,7 @@ from homeassistant.const import (
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
@@ -33,6 +33,7 @@ from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
@@ -223,6 +224,9 @@ class TrackerEntity(
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
__in_zones: list[str] | None = None
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
@@ -256,6 +260,18 @@ class TrackerEntity(
"""Return longitude value of the device."""
return self._attr_longitude
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
super()._async_write_ha_state()
@property
def state(self) -> str | None:
"""Return the state of the device."""
@@ -263,9 +279,7 @@ class TrackerEntity(
return self.location_name
if self.latitude is not None and self.longitude is not None:
zone_state = zone.async_active_zone(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
@@ -278,12 +292,13 @@ class TrackerEntity(
@final
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {}
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_IN_ZONES] = self.__in_zones or []
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy

View File

@@ -43,6 +43,7 @@ ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
ATTR_GPS: Final = "gps"
ATTR_HOST_NAME: Final = "host_name"
ATTR_IN_ZONES: Final = "in_zones"
ATTR_LOCATION_NAME: Final = "location_name"
ATTR_MAC: Final = "mac"
ATTR_SOURCE_TYPE: Final = "source_type"

View File

@@ -0,0 +1,64 @@
"""The Dropbox integration."""
from __future__ import annotations
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxUnknownException,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .auth import DropboxConfigEntryAuth
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Set up Dropbox from a config entry."""
try:
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
auth = DropboxConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), oauth2_session
)
client = DropboxAPIClient(auth)
try:
await client.get_account_info()
except DropboxAuthException as err:
raise ConfigEntryAuthFailed from err
except (DropboxUnknownException, TimeoutError) as err:
raise ConfigEntryNotReady from err
entry.runtime_data = client
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@@ -0,0 +1,38 @@
"""Application credentials platform for the Dropbox integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2Implementation,
LocalOAuth2ImplementationWithPkce,
)
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return DropboxOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
credential.client_secret,
)
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
data: dict = {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
data.update(super().extra_authorize_data)
return data

View File

@@ -0,0 +1,44 @@
"""Authentication for Dropbox."""
from typing import cast
from aiohttp import ClientSession
from python_dropbox_api import Auth
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
class DropboxConfigEntryAuth(Auth):
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
) -> None:
"""Initialize DropboxConfigEntryAuth."""
super().__init__(websession)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
class DropboxConfigFlowAuth(Auth):
"""Provide authentication tied to a fixed token for the config flow."""
def __init__(
self,
websession: ClientSession,
token: str,
) -> None:
"""Initialize DropboxConfigFlowAuth."""
super().__init__(websession)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the fixed access token."""
return self._token

View File

@@ -0,0 +1,230 @@
"""Backup platform for the Dropbox integration."""
from collections.abc import AsyncIterator, Callable, Coroutine
from functools import wraps
import json
import logging
from typing import Any, Concatenate
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxFileOrFolderNotFoundException,
DropboxUnknownException,
)
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from . import DropboxConfigEntry
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata."""
base_name = suggested_filename(backup).rsplit(".", 1)[0]
return f"{base_name}.tar", f"{base_name}.metadata.json"
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
"""Yield a string as a single bytes chunk."""
yield content.encode()
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
"""Handle backup errors."""
@wraps(func)
async def wrapper(
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
) -> _R:
try:
return await func(self, *args, **kwargs)
except DropboxFileOrFolderNotFoundException as err:
raise BackupNotFound(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
except DropboxAuthException as err:
self._entry.async_start_reauth(self._hass)
raise BackupAgentError("Authentication error") from err
except DropboxUnknownException as err:
_LOGGER.error(
"Error during %s: %s",
func.__name__,
err,
)
_LOGGER.debug("Full error: %s", err, exc_info=True)
raise BackupAgentError(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
return wrapper
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Return a list of backup agents."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return [DropboxBackupAgent(hass, entry) for entry in entries]
@callback
def async_register_backup_agents_listener(
hass: HomeAssistant,
*,
listener: Callable[[], None],
**kwargs: Any,
) -> Callable[[], None]:
"""Register a listener to be called when agents are added or removed.
:return: A function to unregister the listener.
"""
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
@callback
def remove_listener() -> None:
"""Remove the listener."""
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
return remove_listener
class DropboxBackupAgent(BackupAgent):
"""Backup agent for the Dropbox integration."""
domain = DOMAIN
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._entry = entry
self.name = entry.title
assert entry.unique_id
self.unique_id = entry.unique_id
self._api: DropboxAPIClient = entry.runtime_data
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
"""Get backups and their corresponding file names."""
files = await self._api.list_folder("")
tar_files = {f.name for f in files if f.name.endswith(".tar")}
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
backups: list[tuple[AgentBackup, str]] = []
for metadata_file in metadata_files:
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
if tar_name not in tar_files:
_LOGGER.warning(
"Found metadata file '%s' without matching backup file",
metadata_file.name,
)
continue
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
raw = b"".join([chunk async for chunk in metadata_stream])
try:
data = json.loads(raw)
backup = AgentBackup.from_dict(data)
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
_LOGGER.warning(
"Skipping invalid metadata file '%s': %s",
metadata_file.name,
err,
)
continue
backups.append((backup, tar_name))
return backups
@handle_backup_errors
async def async_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup."""
backup_filename, metadata_filename = _suggested_filenames(backup)
backup_path = f"/{backup_filename}"
metadata_path = f"/{metadata_filename}"
file_stream = await open_stream()
await self._api.upload_file(backup_path, file_stream)
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
try:
await self._api.upload_file(metadata_path, metadata_stream)
except (
DropboxAuthException,
DropboxUnknownException,
):
await self._api.delete_file(backup_path)
raise
@handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
return [backup for backup, _ in await self._async_get_backups()]
@handle_backup_errors
async def async_download_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
backups = await self._async_get_backups()
for backup, filename in backups:
if backup.backup_id == backup_id:
return self._api.download_file(f"/{filename}")
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
"""Return a backup."""
backups = await self._async_get_backups()
for backup, _ in backups:
if backup.backup_id == backup_id:
return backup
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_delete_backup(
self,
backup_id: str,
**kwargs: Any,
) -> None:
"""Delete a backup file."""
backups = await self._async_get_backups()
for backup, tar_filename in backups:
if backup.backup_id == backup_id:
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
await self._api.delete_file(f"/{tar_filename}")
await self._api.delete_file(f"/{metadata_filename}")
return
raise BackupNotFound(f"Backup {backup_id} not found")

View File

@@ -0,0 +1,60 @@
"""Config flow for Dropbox."""
from collections.abc import Mapping
import logging
from typing import Any
from python_dropbox_api import DropboxAPIClient
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .auth import DropboxConfigFlowAuth
from .const import DOMAIN
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Dropbox OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
client = DropboxAPIClient(auth)
account_info = await client.get_account_info()
await self.async_set_unique_id(account_info.account_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=account_info.email, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@@ -0,0 +1,19 @@
"""Constants for the Dropbox integration."""
from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "dropbox"
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
OAUTH2_SCOPES = [
"account_info.read",
"files.content.read",
"files.content.write",
]
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -0,0 +1,13 @@
{
"domain": "dropbox",
"name": "Dropbox",
"after_dependencies": ["backup"],
"codeowners": ["@bdr99"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/dropbox",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["python-dropbox-api==0.1.3"]
}

View File

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

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with the correct account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The Dropbox integration needs to re-authenticate your account.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -5,7 +5,7 @@
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
},
"error": {
"ambiguous_identifier": "The region identifier and device tracker can not be specified together.",
"ambiguous_identifier": "The region identifier and device tracker cannot be specified together.",
"attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker.",
"entity_not_found": "The specified device tracker entity was not found.",
"invalid_identifier": "The specified region identifier / device tracker is invalid.",

View File

@@ -24,11 +24,10 @@ class EcowittEntity(Entity):
self._attr_unique_id = f"{sensor.station.key}-{sensor.key}"
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, sensor.station.key),
},
identifiers={(DOMAIN, sensor.station.key)},
name=sensor.station.model,
model=sensor.station.model,
manufacturer="Ecowitt",
sw_version=sensor.station.version,
)

View File

@@ -29,9 +29,11 @@ VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
"""ekey bionyx authentication before a ConfigEntry exists.
"""Authentication implementation used during config flow, without refresh.
This implementation directly provides the token without supporting refresh.
This exists to allow the config flow to use the API before it has fully
created a config entry required by OAuth2Session. This does not support
refreshing tokens, which is fine since it should have been just created.
"""
def __init__(

View File

@@ -2,14 +2,26 @@
from __future__ import annotations
from homeassistant.const import Platform
from types import MappingProxyType
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .const import (
CONF_AZIMUTH,
CONF_DAMPING,
CONF_DAMPING_EVENING,
CONF_DAMPING_MORNING,
CONF_DECLINATION,
CONF_MODULES_POWER,
DEFAULT_AZIMUTH,
DEFAULT_DAMPING,
DEFAULT_DECLINATION,
DEFAULT_MODULES_POWER,
DOMAIN,
SUBENTRY_TYPE_PLANE,
)
from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator
@@ -25,14 +37,41 @@ async def async_migrate_entry(
new_options = entry.options.copy()
new_options |= {
CONF_MODULES_POWER: new_options.pop("modules power"),
CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0),
CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0),
CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, DEFAULT_DAMPING),
CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, DEFAULT_DAMPING),
}
hass.config_entries.async_update_entry(
entry, data=entry.data, options=new_options, version=2
)
if entry.version == 2:
# Migrate the main plane from options to a subentry
declination = entry.options.get(CONF_DECLINATION, DEFAULT_DECLINATION)
azimuth = entry.options.get(CONF_AZIMUTH, DEFAULT_AZIMUTH)
modules_power = entry.options.get(CONF_MODULES_POWER, DEFAULT_MODULES_POWER)
subentry = ConfigSubentry(
data=MappingProxyType(
{
CONF_DECLINATION: declination,
CONF_AZIMUTH: azimuth,
CONF_MODULES_POWER: modules_power,
}
),
subentry_type=SUBENTRY_TYPE_PLANE,
title=f"{declination}° / {azimuth}° / {modules_power}W",
unique_id=None,
)
hass.config_entries.async_add_subentry(entry, subentry)
new_options = dict(entry.options)
new_options.pop(CONF_DECLINATION, None)
new_options.pop(CONF_AZIMUTH, None)
new_options.pop(CONF_MODULES_POWER, None)
hass.config_entries.async_update_entry(entry, options=new_options, version=3)
return True
@@ -40,6 +79,19 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ForecastSolarConfigEntry
) -> bool:
"""Set up Forecast.Solar from a config entry."""
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
if not plane_subentries:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_plane",
)
if len(plane_subentries) > 1 and not entry.options.get(CONF_API_KEY):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_key_required",
)
coordinator = ForecastSolarDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
@@ -47,9 +99,18 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(
hass: HomeAssistant, entry: ForecastSolarConfigEntry
) -> None:
"""Handle config entry updates (options or subentry changes)."""
hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: ForecastSolarConfigEntry
) -> bool:

View File

@@ -11,11 +11,13 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, selector
from .const import (
CONF_AZIMUTH,
@@ -24,16 +26,51 @@ from .const import (
CONF_DECLINATION,
CONF_INVERTER_SIZE,
CONF_MODULES_POWER,
DEFAULT_AZIMUTH,
DEFAULT_DAMPING,
DEFAULT_DECLINATION,
DEFAULT_MODULES_POWER,
DOMAIN,
MAX_PLANES,
SUBENTRY_TYPE_PLANE,
)
RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$")
PLANE_SCHEMA = vol.Schema(
{
vol.Required(CONF_DECLINATION): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=90, step=1, mode=selector.NumberSelectorMode.BOX
),
),
vol.Coerce(int),
),
vol.Required(CONF_AZIMUTH): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=360, step=1, mode=selector.NumberSelectorMode.BOX
),
),
vol.Coerce(int),
),
vol.Required(CONF_MODULES_POWER): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, step=1, mode=selector.NumberSelectorMode.BOX
),
),
vol.Coerce(int),
),
}
)
class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Forecast.Solar."""
VERSION = 2
VERSION = 3
@staticmethod
@callback
@@ -43,6 +80,14 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return ForecastSolarOptionFlowHandler()
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {SUBENTRY_TYPE_PLANE: PlaneSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -54,94 +99,112 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_LATITUDE: user_input[CONF_LATITUDE],
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
},
options={
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
CONF_DECLINATION: user_input[CONF_DECLINATION],
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
},
subentries=[
{
"subentry_type": SUBENTRY_TYPE_PLANE,
"data": {
CONF_DECLINATION: user_input[CONF_DECLINATION],
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
},
"title": f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
"unique_id": None,
},
],
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
}
).extend(PLANE_SCHEMA.schema),
{
vol.Required(
CONF_NAME, default=self.hass.config.location_name
): str,
vol.Required(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,
vol.Required(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Required(CONF_DECLINATION, default=25): vol.All(
vol.Coerce(int), vol.Range(min=0, max=90)
),
vol.Required(CONF_AZIMUTH, default=180): vol.All(
vol.Coerce(int), vol.Range(min=0, max=360)
),
vol.Required(CONF_MODULES_POWER): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
}
CONF_NAME: self.hass.config.location_name,
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
CONF_DECLINATION: DEFAULT_DECLINATION,
CONF_AZIMUTH: DEFAULT_AZIMUTH,
CONF_MODULES_POWER: DEFAULT_MODULES_POWER,
},
),
)
class ForecastSolarOptionFlowHandler(OptionsFlowWithReload):
class ForecastSolarOptionFlowHandler(OptionsFlow):
"""Handle options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors = {}
errors: dict[str, str] = {}
planes_count = len(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
)
if user_input is not None:
if (api_key := user_input.get(CONF_API_KEY)) and RE_API_KEY.match(
api_key
) is None:
api_key = user_input.get(CONF_API_KEY)
if planes_count > 1 and not api_key:
errors[CONF_API_KEY] = "api_key_required"
elif api_key and RE_API_KEY.match(api_key) is None:
errors[CONF_API_KEY] = "invalid_api_key"
else:
return self.async_create_entry(
title="", data=user_input | {CONF_API_KEY: api_key or None}
)
suggested_api_key = self.config_entry.options.get(CONF_API_KEY, "")
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
vol.Required(
CONF_API_KEY,
description={
"suggested_value": self.config_entry.options.get(
CONF_API_KEY, ""
)
},
default=suggested_api_key,
)
if planes_count > 1
else vol.Optional(
CONF_API_KEY,
description={"suggested_value": suggested_api_key},
): str,
vol.Required(
CONF_DECLINATION,
default=self.config_entry.options[CONF_DECLINATION],
): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)),
vol.Required(
CONF_AZIMUTH,
default=self.config_entry.options.get(CONF_AZIMUTH),
): vol.All(vol.Coerce(int), vol.Range(min=-0, max=360)),
vol.Required(
CONF_MODULES_POWER,
default=self.config_entry.options[CONF_MODULES_POWER],
): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(
CONF_DAMPING_MORNING,
default=self.config_entry.options.get(
CONF_DAMPING_MORNING, 0.0
CONF_DAMPING_MORNING, DEFAULT_DAMPING
),
): vol.Coerce(float),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=1,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(float),
),
vol.Optional(
CONF_DAMPING_EVENING,
default=self.config_entry.options.get(
CONF_DAMPING_EVENING, 0.0
CONF_DAMPING_EVENING, DEFAULT_DAMPING
),
): vol.Coerce(float),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=1,
step=0.01,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(float),
),
vol.Optional(
CONF_INVERTER_SIZE,
description={
@@ -149,8 +212,89 @@ class ForecastSolarOptionFlowHandler(OptionsFlowWithReload):
CONF_INVERTER_SIZE
)
},
): vol.Coerce(int),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
step=1,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(int),
),
}
),
errors=errors,
)
class PlaneSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding/editing a plane."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the user step to add a new plane."""
entry = self._get_entry()
planes_count = len(entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE))
if planes_count >= MAX_PLANES:
return self.async_abort(reason="max_planes")
if planes_count >= 1 and not entry.options.get(CONF_API_KEY):
return self.async_abort(reason="api_key_required")
if user_input is not None:
return self.async_create_entry(
title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
data={
CONF_DECLINATION: user_input[CONF_DECLINATION],
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
PLANE_SCHEMA,
{
CONF_DECLINATION: DEFAULT_DECLINATION,
CONF_AZIMUTH: DEFAULT_AZIMUTH,
CONF_MODULES_POWER: DEFAULT_MODULES_POWER,
},
),
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of an existing plane."""
subentry = self._get_reconfigure_subentry()
if user_input is not None:
entry = self._get_entry()
if self._async_update(
entry,
subentry,
data={
CONF_DECLINATION: user_input[CONF_DECLINATION],
CONF_AZIMUTH: user_input[CONF_AZIMUTH],
CONF_MODULES_POWER: user_input[CONF_MODULES_POWER],
},
title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W",
):
if not entry.update_listeners:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reconfigure_successful")
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
PLANE_SCHEMA,
{
CONF_DECLINATION: subentry.data[CONF_DECLINATION],
CONF_AZIMUTH: subentry.data[CONF_AZIMUTH],
CONF_MODULES_POWER: subentry.data[CONF_MODULES_POWER],
},
),
)

View File

@@ -14,3 +14,9 @@ CONF_DAMPING = "damping"
CONF_DAMPING_MORNING = "damping_morning"
CONF_DAMPING_EVENING = "damping_evening"
CONF_INVERTER_SIZE = "inverter_size"
DEFAULT_DECLINATION = 25
DEFAULT_AZIMUTH = 180
DEFAULT_MODULES_POWER = 10000
DEFAULT_DAMPING = 0.0
MAX_PLANES = 4
SUBENTRY_TYPE_PLANE = "plane"

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import timedelta
from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError
from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError, Plane
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
@@ -19,8 +19,10 @@ from .const import (
CONF_DECLINATION,
CONF_INVERTER_SIZE,
CONF_MODULES_POWER,
DEFAULT_DAMPING,
DOMAIN,
LOGGER,
SUBENTRY_TYPE_PLANE,
)
type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator]
@@ -30,6 +32,7 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]):
"""The Forecast.Solar Data Update Coordinator."""
config_entry: ForecastSolarConfigEntry
forecast: ForecastSolar
def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None:
"""Initialize the Forecast.Solar coordinator."""
@@ -43,17 +46,34 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]):
) is not None and inverter_size > 0:
inverter_size = inverter_size / 1000
# Build the list of planes from subentries.
plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)
# The first plane subentry is the main plane
main_plane = plane_subentries[0]
# Additional planes
planes: list[Plane] = [
Plane(
declination=subentry.data[CONF_DECLINATION],
azimuth=(subentry.data[CONF_AZIMUTH] - 180),
kwp=(subentry.data[CONF_MODULES_POWER] / 1000),
)
for subentry in plane_subentries[1:]
]
self.forecast = ForecastSolar(
api_key=api_key,
session=async_get_clientsession(hass),
latitude=entry.data[CONF_LATITUDE],
longitude=entry.data[CONF_LONGITUDE],
declination=entry.options[CONF_DECLINATION],
azimuth=(entry.options[CONF_AZIMUTH] - 180),
kwp=(entry.options[CONF_MODULES_POWER] / 1000),
damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0),
damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0),
declination=main_plane.data[CONF_DECLINATION],
azimuth=(main_plane.data[CONF_AZIMUTH] - 180),
kwp=(main_plane.data[CONF_MODULES_POWER] / 1000),
damping_morning=entry.options.get(CONF_DAMPING_MORNING, DEFAULT_DAMPING),
damping_evening=entry.options.get(CONF_DAMPING_EVENING, DEFAULT_DAMPING),
inverter=inverter_size,
planes=planes,
)
# Free account have a resolution of 1 hour, using that as the default

View File

@@ -28,6 +28,13 @@ async def async_get_config_entry_diagnostics(
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
"subentries": [
{
"data": dict(subentry.data),
"title": subentry.title,
}
for subentry in entry.subentries.values()
],
},
"data": {
"energy_production_today": coordinator.data.energy_production_today,

View File

@@ -14,6 +14,37 @@
}
}
},
"config_subentries": {
"plane": {
"abort": {
"api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.",
"max_planes": "You can add a maximum of 4 planes.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Plane",
"initiate_flow": {
"user": "Add plane"
},
"step": {
"reconfigure": {
"data": {
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
},
"description": "Edit the solar plane configuration."
},
"user": {
"data": {
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
},
"description": "Add a solar plane. Multiple planes are supported with a Forecast.Solar API subscription."
}
}
}
},
"entity": {
"sensor": {
"energy_current_hour": {
@@ -51,20 +82,26 @@
}
}
},
"exceptions": {
"api_key_required": {
"message": "An API key is required when more than one plane is configured"
},
"no_plane": {
"message": "No plane configured, cannot set up Forecast.Solar"
}
},
"options": {
"error": {
"api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
},
"step": {
"init": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]",
"damping_evening": "Damping factor: adjusts the results in the evening",
"damping_morning": "Damping factor: adjusts the results in the morning",
"declination": "[%key:component::forecast_solar::config::step::user::data::declination%]",
"inverter_size": "Inverter size (Watt)",
"modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]"
"inverter_size": "Inverter size (Watt)"
},
"description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear."
}

View File

@@ -14,11 +14,12 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import _is_tracked
@@ -63,10 +64,38 @@ BUTTONS: Final = [
translation_key="cleanup",
entity_category=EntityCategory.CONFIG,
press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(),
entity_registry_enabled_default=False,
),
]
def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
"""Repair issue for cleanup button."""
entity_registry = er.async_get(hass)
if (
(
entity_button := entity_registry.async_get_entity_id(
"button", DOMAIN, f"{avm_wrapper.unique_id}-cleanup"
)
)
and (entity_entry := entity_registry.async_get(entity_button))
and not entity_entry.disabled
):
# Deprecate the 'cleanup' button: create a Repairs issue for users
ir.async_create_issue(
hass,
domain=DOMAIN,
issue_id="deprecated_cleanup_button",
is_fixable=False,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_cleanup_button",
translation_placeholders={"removal_version": "2026.11.0"},
breaks_in_ha_version="2026.11.0",
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
@@ -82,6 +111,7 @@ async def async_setup_entry(
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
async_add_entities(entities_list)
repair_issue_cleanup(hass, avm_wrapper)
return
data_fritz = hass.data[FRITZ_DATA_KEY]
@@ -100,6 +130,8 @@ async def async_setup_entry(
)
)
repair_issue_cleanup(hass, avm_wrapper)
class FritzButton(ButtonEntity):
"""Defines a Fritz!Box base button."""
@@ -126,6 +158,12 @@ class FritzButton(ButtonEntity):
async def async_press(self) -> None:
"""Triggers Fritz!Box service."""
if self.entity_description.key == "cleanup":
_LOGGER.warning(
"The 'cleanup' button is deprecated and will be removed in Home Assistant Core 2026.11.0. "
"Please update your automations and dashboards to remove any usage of this button. "
"The action is now performed automatically at each data refresh",
)
await self.entity_description.press_action(self.avm_wrapper)

View File

@@ -332,7 +332,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
translation_placeholders={"error": str(ex)},
) from ex
_LOGGER.debug("enity_data: %s", entity_data)
_LOGGER.debug("entity_data: %s", entity_data)
await self.async_trigger_cleanup()
return entity_data
@property

View File

@@ -10,9 +10,11 @@ from requests.exceptions import RequestException
from homeassistant.components.image import ImageEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .const import DOMAIN, Platform
from .coordinator import AvmWrapper, FritzConfigEntry
from .entity import FritzBoxBaseEntity
@@ -22,6 +24,32 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def _migrate_to_new_unique_id(
hass: HomeAssistant, avm_wrapper: AvmWrapper, ssid: str
) -> None:
"""Migrate old unique id to new unique id."""
old_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
new_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
entity_registry = er.async_get(hass)
entity_id = entity_registry.async_get_entity_id(
Platform.IMAGE,
DOMAIN,
old_unique_id,
)
if entity_id is None:
return
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
_LOGGER.debug(
"Migrating guest Wi-Fi image unique_id from [%s] to [%s]",
old_unique_id,
new_unique_id,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
@@ -34,6 +62,8 @@ async def async_setup_entry(
avm_wrapper.fritz_guest_wifi.get_info
)
await _migrate_to_new_unique_id(hass, avm_wrapper, guest_wifi_info["NewSSID"])
async_add_entities(
[
FritzGuestWifiQRImage(
@@ -60,7 +90,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
) -> None:
"""Initialize the image entity."""
self._attr_name = ssid
self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
self._attr_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
self._current_qr_bytes: bytes | None = None
super().__init__(avm_wrapper, device_friendly_name)
ImageEntity.__init__(self, hass)

View File

@@ -56,9 +56,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: todo
comment: automate the current cleanup process and deprecate the corresponding button
stale-devices: done
# Platinum
async-dependency:

View File

@@ -195,6 +195,12 @@
"message": "Error while updating the data: {error}"
}
},
"issues": {
"deprecated_cleanup_button": {
"description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.",
"title": "'Cleanup' button is deprecated"
}
},
"options": {
"step": {
"init": {

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260325.5"]
"requirements": ["home-assistant-frontend==20260325.6"]
}

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["afsapi==0.2.7"],
"requirements": ["afsapi==0.3.1"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"

View File

@@ -4,7 +4,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve
from gardena_bluetooth.const import (
AquaContourWatering,
DeviceConfiguration,
Sensor,
Spray,
Valve,
)
from gardena_bluetooth.parse import (
Characteristic,
CharacteristicInt,
@@ -58,6 +64,18 @@ DESCRIPTIONS = (
char=Valve.manual_watering_time,
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=AquaContourWatering.manual_watering_time.unique_id,
translation_key="manual_watering_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
native_min_value=0.0,
native_max_value=24 * 60 * 60,
native_step=60,
entity_category=EntityCategory.CONFIG,
char=AquaContourWatering.manual_watering_time,
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Valve.remaining_open_time.unique_id,
translation_key="remaining_open_time",
@@ -69,6 +87,17 @@ DESCRIPTIONS = (
char=Valve.remaining_open_time,
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=AquaContourWatering.remaining_watering_time.unique_id,
translation_key="remaining_watering_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=0.0,
native_max_value=24 * 60 * 60,
native_step=60.0,
entity_category=EntityCategory.DIAGNOSTIC,
char=AquaContourWatering.remaining_watering_time,
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.rain_pause.unique_id,
translation_key="rain_pause",

View File

@@ -50,6 +50,9 @@
"remaining_open_time": {
"name": "Remaining open time"
},
"remaining_watering_time": {
"name": "Remaining watering time"
},
"seasonal_adjust": {
"name": "Seasonal adjust"
},

View File

@@ -68,7 +68,7 @@
"name": "Worksheet"
}
},
"name": "Append to sheet"
"name": "Append data to Google sheet"
},
"get_sheet": {
"description": "Gets data from a worksheet in Google Sheets.",
@@ -86,7 +86,7 @@
"name": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::name%]"
}
},
"name": "Get data from sheet"
"name": "Get data from Google sheet"
}
}
}

View File

@@ -8,7 +8,7 @@ import growattServer
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -64,6 +64,16 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
menu_options=["password_auth", "token_auth"],
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self._async_step_credentials(
step_id="reconfigure",
entry=self._get_reconfigure_entry(),
user_input=user_input,
)
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle reauth."""
return await self.async_step_reauth_confirm()
@@ -72,11 +82,23 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation."""
return await self._async_step_credentials(
step_id="reauth_confirm",
entry=self._get_reauth_entry(),
user_input=user_input,
)
async def _async_step_credentials(
self,
step_id: str,
entry: ConfigEntry,
user_input: dict[str, Any] | None,
) -> ConfigFlowResult:
"""Handle credential update for both reauth and reconfigure."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
auth_type = entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
@@ -91,17 +113,19 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.debug("Network error during reauth login: %s", ex)
_LOGGER.debug("Network error during credential update: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
_LOGGER.debug(
"Invalid response format during credential update: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
else:
if not isinstance(login_response, dict):
errors["base"] = ERROR_CANNOT_CONNECT
elif login_response.get("success"):
return self.async_update_reload_and_abort(
reauth_entry,
entry,
data_updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
@@ -121,28 +145,26 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.hass.async_add_executor_job(api.plant_list)
except requests.exceptions.RequestException as ex:
_LOGGER.debug(
"Network error during reauth token validation: %s", ex
)
_LOGGER.debug("Network error during credential update: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
errors["base"] = ERROR_INVALID_AUTH
else:
_LOGGER.debug(
"Growatt V1 API error during reauth: %s (Code: %s)",
"Growatt V1 API error during credential update: %s (Code: %s)",
err.error_msg or str(err),
err.error_code,
)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug(
"Invalid response format during reauth token validation: %s", ex
"Invalid response format during credential update: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
else:
return self.async_update_reload_and_abort(
reauth_entry,
entry,
data_updates={
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_URL: server_url,
@@ -151,19 +173,19 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
# Determine the current region key from the stored config value.
# Legacy entries may store the region key directly; newer entries store the URL.
stored_url = reauth_entry.data.get(CONF_URL, "")
stored_url = entry.data.get(CONF_URL, "")
if stored_url in SERVER_URLS_NAMES:
current_region = stored_url
else:
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
auth_type = entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME,
default=reauth_entry.data.get(CONF_USERNAME),
default=entry.data.get(CONF_USERNAME),
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
@@ -189,8 +211,18 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
if user_input is not None:
data_schema = self.add_suggested_values_to_schema(
data_schema,
{
key: value
for key, value in user_input.items()
if key not in (CONF_PASSWORD, CONF_TOKEN)
},
)
return self.async_show_form(
step_id="reauth_confirm",
step_id=step_id,
data_schema=data_schema,
errors=errors,
)

View File

@@ -50,7 +50,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: Integration does not raise repairable issues.

View File

@@ -4,7 +4,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
@@ -49,6 +50,22 @@
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]",
"username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]"
},
"description": "Update your credentials to continue using this integration.",
"title": "Reconfigure Growatt"
},
"token_auth": {
"data": {
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.84", "babel==2.15.0"]
"requirements": ["holidays==0.93", "babel==2.15.0"]
}

View File

@@ -49,14 +49,21 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.components.water_heater import (
ATTR_OPERATION_LIST,
ATTR_OPERATION_MODE,
DOMAIN as WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER,
WaterHeaterEntityFeature,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
PERCENTAGE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
@@ -745,6 +752,7 @@ class WaterHeater(HomeAccessory):
(
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_OPERATION_LIST,
)
)
self._unit = self.hass.config.units.temperature_unit
@@ -752,6 +760,20 @@ class WaterHeater(HomeAccessory):
assert state
min_temp, max_temp = self.get_temperature_range(state)
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
operation_list = state.attributes.get(ATTR_OPERATION_LIST) or []
self._supports_on_off = bool(features & WaterHeaterEntityFeature.ON_OFF)
self._supports_operation_mode = bool(
features & WaterHeaterEntityFeature.OPERATION_MODE
)
self._off_mode_available = self._supports_on_off or (
self._supports_operation_mode and STATE_OFF in operation_list
)
valid_modes = dict(HC_HOMEKIT_VALID_MODES_WATER_HEATER)
if self._off_mode_available:
valid_modes["Off"] = HC_HEAT_COOL_OFF
serv_thermostat = self.add_preload_service(SERV_THERMOSTAT)
self.char_current_heat_cool = serv_thermostat.configure_char(
@@ -761,7 +783,7 @@ class WaterHeater(HomeAccessory):
CHAR_TARGET_HEATING_COOLING,
value=1,
setter_callback=self.set_heat_cool,
valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER,
valid_values=valid_modes,
)
self.char_current_temp = serv_thermostat.configure_char(
@@ -795,8 +817,48 @@ class WaterHeater(HomeAccessory):
def set_heat_cool(self, value: int) -> None:
"""Change operation mode to value if call came from HomeKit."""
_LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value)
if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT:
self.char_target_heat_cool.set_value(1) # Heat
params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id}
if value == HC_HEAT_COOL_OFF:
if self._supports_on_off:
self.async_call_service(
WATER_HEATER_DOMAIN, SERVICE_TURN_OFF, params, "off"
)
elif self._off_mode_available and self._supports_operation_mode:
params[ATTR_OPERATION_MODE] = STATE_OFF
self.async_call_service(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
params,
STATE_OFF,
)
else:
self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT)
elif value == HC_HEAT_COOL_HEAT:
if self._supports_on_off:
self.async_call_service(
WATER_HEATER_DOMAIN, SERVICE_TURN_ON, params, "on"
)
elif self._off_mode_available and self._supports_operation_mode:
state = self.hass.states.get(self.entity_id)
if not state:
return
current_operation_mode = state.attributes.get(ATTR_OPERATION_MODE)
if current_operation_mode and current_operation_mode != STATE_OFF:
# Already in a non-off operation mode; do not change it.
return
operation_list = state.attributes.get(ATTR_OPERATION_LIST) or []
for mode in operation_list:
if mode != STATE_OFF:
params[ATTR_OPERATION_MODE] = mode
self.async_call_service(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
params,
mode,
)
break
else:
self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT)
def set_target_temperature(self, value: float) -> None:
"""Set target temperature to value if call came from HomeKit."""
@@ -829,7 +891,12 @@ class WaterHeater(HomeAccessory):
# Update target operation mode
if new_state.state:
self.char_target_heat_cool.set_value(1) # Heat
if new_state.state == STATE_OFF and self._off_mode_available:
self.char_target_heat_cool.set_value(HC_HEAT_COOL_OFF)
self.char_current_heat_cool.set_value(HC_HEAT_COOL_OFF)
else:
self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT)
self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT)
def _get_temperature_range_from_state(

View File

@@ -10,6 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
"requirements": ["aiohue==4.8.0"],
"requirements": ["aiohue==4.8.1"],
"zeroconf": ["_hue._tcp.local."]
}

View File

@@ -69,6 +69,10 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
@callback
def _handle_coordinator_update(self) -> None:
"""Get the latest data and updates the state."""
# Guard against updates arriving after the controller has been removed
# but before the entity has been unsubscribed from the coordinator.
if self.controller.id not in self.coordinator.data.controllers:
return
self.controller = self.coordinator.data.controllers[self.controller.id]
self._update_attrs()
super()._handle_coordinator_update()

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hyponcloud==0.9.0"]
"requirements": ["hyponcloud==0.9.3"]
}

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.1.4"],
"requirements": ["pykaleidescape==1.1.5"],
"ssdp": [
{
"deviceType": "schemas-upnp-org:device:Basic:1",

View File

@@ -72,7 +72,7 @@
"cold_tea": {
"fix_flow": {
"abort": {
"not_tea_time": "Can not re-heat the tea at this time"
"not_tea_time": "Cannot reheat the tea at this time"
},
"step": {}
},

View File

@@ -2,111 +2,29 @@
from __future__ import annotations
import logging
import socket
from typing import Any
from urllib.parse import urlencode
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.components.notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
DOMAIN = "lannouncer"
ATTR_METHOD = "method"
ATTR_METHOD_DEFAULT = "speak"
ATTR_METHOD_ALLOWED = ["speak", "alarm"]
DEFAULT_PORT = 1035
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
def get_service(
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> LannouncerNotificationService:
) -> None:
"""Get the Lannouncer notification service."""
@callback
def _async_create_issue() -> None:
"""Create issue for removed integration."""
ir.async_create_issue(
hass,
DOMAIN,
"integration_removed",
is_fixable=False,
breaks_in_ha_version="2026.3.0",
severity=ir.IssueSeverity.WARNING,
translation_key="integration_removed",
)
hass.add_job(_async_create_issue)
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
return LannouncerNotificationService(hass, host, port)
class LannouncerNotificationService(BaseNotificationService):
"""Implementation of a notification service for Lannouncer."""
def __init__(self, hass, host, port):
"""Initialize the service."""
self._hass = hass
self._host = host
self._port = port
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Lannouncer."""
data = kwargs.get(ATTR_DATA)
if data is not None and ATTR_METHOD in data:
method = data.get(ATTR_METHOD)
else:
method = ATTR_METHOD_DEFAULT
if method not in ATTR_METHOD_ALLOWED:
_LOGGER.error("Unknown method %s", method)
return
cmd = urlencode({method: message})
try:
# Open socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((self._host, self._port))
# Send message
_LOGGER.debug("Sending message: %s", cmd)
sock.sendall(cmd.encode())
sock.sendall(b"&@DONE@\n")
# Check response
buffer = sock.recv(1024)
if buffer != b"LANnouncer: OK":
_LOGGER.error("Error sending data to Lannnouncer: %s", buffer.decode())
# Close socket
sock.close()
except socket.gaierror:
_LOGGER.error("Unable to connect to host %s", self._host)
except OSError:
_LOGGER.exception("Failed to send data to Lannnouncer")
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
)

View File

@@ -1,8 +1,8 @@
{
"issues": {
"integration_removed": {
"description": "The LANnouncer Android app is no longer available, so this integration has been deprecated and will be removed in a future release.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.",
"title": "LANnouncer integration is deprecated"
"description": "The LANnouncer integration has been removed from Home Assistant because the LANnouncer Android app is no longer available.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.",
"title": "LANnouncer integration has been removed"
}
}
}

View File

@@ -4,7 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"not_readable_path": "The provided path to the file can not be read"
"not_readable_path": "The provided path to the file cannot be read"
},
"step": {
"user": {

View File

@@ -1,5 +1,6 @@
"""The Lunatone integration."""
import logging
from typing import Final
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
@@ -7,9 +8,10 @@ from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .config_flow import LunatoneConfigFlow
from .const import DOMAIN, MANUFACTURER
from .coordinator import (
LunatoneConfigEntry,
@@ -18,9 +20,51 @@ from .coordinator import (
LunatoneInfoDataUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
async def _update_unique_id(
hass: HomeAssistant, entry: LunatoneConfigEntry, new_unique_id: str
) -> None:
_LOGGER.debug("Update unique ID")
# Update all associated entities
entity_registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
for entity in entities:
parts = list(entity.unique_id.partition("-"))
parts[0] = new_unique_id
entity_registry.async_update_entity(
entity.entity_id, new_unique_id="".join(parts)
)
# Update all associated devices
device_registry = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
for device in devices:
identifier = device.identifiers.pop()
parts = list(identifier[1].partition("-"))
parts[0] = new_unique_id
device_registry.async_update_device(
device.id, new_identifiers={(identifier[0], "".join(parts))}
)
# Update the config entry itself
hass.config_entries.async_update_entry(
entry,
unique_id=new_unique_id,
minor_version=LunatoneConfigFlow.MINOR_VERSION,
version=LunatoneConfigFlow.VERSION,
)
_LOGGER.debug("Update of unique ID successful")
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
@@ -30,15 +74,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
if info_api.serial_number is None:
if info_api.data is None or info_api.serial_number is None:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
if info_api.uid is not None:
new_unique_id = info_api.uid.replace("-", "")
if new_unique_id != entry.unique_id:
await _update_unique_id(hass, entry, new_unique_id)
assert entry.unique_id
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(info_api.serial_number))},
identifiers={(DOMAIN, entry.unique_id)},
name=info_api.name,
manufacturer=MANUFACTURER,
sw_version=info_api.version,

View File

@@ -52,14 +52,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
if info_api.serial_number is None:
errors["base"] = "missing_device_info"
else:
await self.async_set_unique_id(str(info_api.serial_number))
unique_id = str(info_api.serial_number)
if info_api.uid is not None:
unique_id = info_api.uid.replace("-", "")
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=data, title=url
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=url, data={CONF_URL: url})
return self.async_create_entry(title=url, data=data)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,

View File

@@ -41,17 +41,20 @@ async def async_setup_entry(
coordinator_devices = config_entry.runtime_data.coordinator_devices
dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts
assert config_entry.unique_id is not None
entities: list[LightEntity] = [
LunatoneLineBroadcastLight(
coordinator_info, coordinator_devices, dali_line_broadcast
coordinator_info,
coordinator_devices,
dali_line_broadcast,
config_entry.unique_id,
)
for dali_line_broadcast in dali_line_broadcasts
]
entities.extend(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
)
LunatoneLight(coordinator_devices, device_id, config_entry.unique_id)
for device_id in coordinator_devices.data
]
)
@@ -76,14 +79,14 @@ class LunatoneLight(
self,
coordinator: LunatoneDevicesDataUpdateCoordinator,
device_id: int,
interface_serial_number: int,
config_entry_unique_id: str,
) -> None:
"""Initialize a Lunatone light."""
super().__init__(coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data[self._device_id]
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
self._config_entry_unique_id = config_entry_unique_id
self._device = self.coordinator.data[device_id]
self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
@@ -94,7 +97,7 @@ class LunatoneLight(
name=self._device.name,
via_device=(
DOMAIN,
f"{self._interface_serial_number}-line{self._device.data.line}",
f"{self._config_entry_unique_id}-line{self._device.data.line}",
),
)
@@ -179,6 +182,7 @@ class LunatoneLineBroadcastLight(
coordinator_info: LunatoneInfoDataUpdateCoordinator,
coordinator_devices: LunatoneDevicesDataUpdateCoordinator,
broadcast: DALIBroadcast,
config_entry_unique_id: str,
) -> None:
"""Initialize a Lunatone line broadcast light."""
super().__init__(coordinator_info)
@@ -187,7 +191,7 @@ class LunatoneLineBroadcastLight(
line = broadcast.line
self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}"
self._attr_unique_id = f"{config_entry_unique_id}-line{line}"
line_device = self.coordinator.data.lines[str(line)].device
extra_info: dict = {}
@@ -202,7 +206,7 @@ class LunatoneLineBroadcastLight(
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"DALI Line {line}",
via_device=(DOMAIN, str(coordinator_info.data.device.serial)),
via_device=(DOMAIN, config_entry_unique_id),
**extra_info,
)

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -132,6 +132,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
(0x1209, 0x8027),
(0x1209, 0x8028),
(0x1209, 0x8029),
(0x138C, 0x0101),
}
SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
@@ -172,6 +173,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
(0x1209, 0x8028),
(0x1209, 0x8029),
(0x131A, 0x1000),
(0x138C, 0x0101),
}
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum

View File

@@ -323,7 +323,11 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.FanControl.Attributes.FanMode,
clusters.FanControl.Attributes.PercentCurrent,
clusters.FanControl.Attributes.PercentSetting,
),
# PercentSetting SHALL be null when FanMode is Auto (spec 4.4.6.3),
# so allow null values to not block discovery in that state.
allow_none_value=True,
optional_attributes=(
clusters.FanControl.Attributes.SpeedSetting,
clusters.FanControl.Attributes.RockSetting,

View File

@@ -399,6 +399,47 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
# HoldTime is shared by PIR-specific numbers as a required attribute.
# Keep discovery open so this generic schema does not block them.
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="OccupancySensingPIRUnoccupiedToOccupiedDelay",
entity_category=EntityCategory.CONFIG,
translation_key="detection_delay",
native_max_value=65534,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
required_attributes=(
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay,
# This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present
clusters.OccupancySensing.Attributes.HoldTime,
),
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="OccupancySensingPIRUnoccupiedToOccupiedThreshold",
entity_category=EntityCategory.CONFIG,
translation_key="detection_threshold",
native_max_value=254,
native_min_value=1,
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
required_attributes=(
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold,
clusters.OccupancySensing.Attributes.HoldTime,
),
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,

View File

@@ -223,6 +223,12 @@
"cook_time": {
"name": "Cooking time"
},
"detection_delay": {
"name": "Detection delay"
},
"detection_threshold": {
"name": "Detection threshold"
},
"hold_time": {
"name": "Hold time"
},

View File

@@ -168,10 +168,15 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
self._attr_target_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
system_mode = self.get_matter_attribute_value(
clusters.Thermostat.Attributes.SystemMode
)
boost_state = self.get_matter_attribute_value(
clusters.WaterHeaterManagement.Attributes.BoostState
)
if boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive:
if system_mode == clusters.Thermostat.Enums.SystemModeEnum.kOff:
self._attr_current_operation = STATE_OFF
elif boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive:
self._attr_current_operation = STATE_HIGH_DEMAND
else:
self._attr_current_operation = STATE_ECO
@@ -218,6 +223,7 @@ DISCOVERY_SCHEMAS = [
clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit,
clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit,
clusters.Thermostat.Attributes.LocalTemperature,
clusters.Thermostat.Attributes.SystemMode,
clusters.WaterHeaterManagement.Attributes.FeatureMap,
),
optional_attributes=(

View File

@@ -260,7 +260,7 @@
},
"clear_playlist": {
"description": "Removes all items from a media player's playlist.",
"name": "Clear playlist"
"name": "Clear media player playlist"
},
"join": {
"description": "Groups media players together for synchronous playback. Only works on supported multiroom audio systems.",
@@ -270,44 +270,44 @@
"name": "Group members"
}
},
"name": "Join"
"name": "Join media players"
},
"media_next_track": {
"description": "Selects the next track.",
"name": "Next"
"description": "Selects the next track on a media player.",
"name": "Next track"
},
"media_pause": {
"description": "Pauses playback on a media player.",
"name": "[%key:common::action::pause%]"
"name": "Pause media"
},
"media_play": {
"description": "Starts playback on a media player.",
"name": "Play"
"name": "Play media"
},
"media_play_pause": {
"description": "Toggles play/pause on a media player.",
"name": "Play/Pause"
"name": "Play/Pause media"
},
"media_previous_track": {
"description": "Selects the previous track.",
"name": "Previous"
"description": "Selects the previous track on a media player.",
"name": "Previous track"
},
"media_seek": {
"description": "Allows you to go to a different part of the media that is currently playing.",
"description": "Allows you to go to a different part of the media that is currently playing on a media player.",
"fields": {
"seek_position": {
"description": "Target position in the currently playing media. The format is platform dependent.",
"name": "Position"
}
},
"name": "Seek"
"name": "Seek media"
},
"media_stop": {
"description": "Stops playback on a media player.",
"name": "[%key:common::action::stop%]"
"name": "Stop media"
},
"play_media": {
"description": "Starts playing specified media.",
"description": "Starts playing specified media on a media player.",
"fields": {
"announce": {
"description": "If the media should be played as an announcement.",
@@ -325,14 +325,14 @@
"name": "Play media"
},
"repeat_set": {
"description": "Sets the repeat mode.",
"description": "Sets the repeat mode of a media player.",
"fields": {
"repeat": {
"description": "Whether the media (one or all) should be played in a loop or not.",
"name": "Repeat mode"
}
},
"name": "Set repeat"
"name": "Set media player repeat"
},
"search_media": {
"description": "Searches the available media.",
@@ -357,14 +357,14 @@
"name": "Search media"
},
"select_sound_mode": {
"description": "Selects a specific sound mode.",
"description": "Selects a specific sound mode of a media player.",
"fields": {
"sound_mode": {
"description": "Name of the sound mode to switch to.",
"name": "Sound mode"
}
},
"name": "Select sound mode"
"name": "Select media player sound mode"
},
"select_source": {
"description": "Sends a media player the command to change the input source.",
@@ -374,37 +374,37 @@
"name": "Source"
}
},
"name": "Select source"
"name": "Select media player source"
},
"shuffle_set": {
"description": "Enables or disables the shuffle mode.",
"description": "Enables or disables the shuffle mode of a media player.",
"fields": {
"shuffle": {
"description": "Whether the media should be played in randomized order or not.",
"name": "Shuffle mode"
}
},
"name": "Set shuffle"
"name": "Set media player shuffle"
},
"toggle": {
"description": "Toggles a media player on/off.",
"name": "[%key:common::action::toggle%]"
"name": "Toggle media player"
},
"turn_off": {
"description": "Turns off the power of a media player.",
"name": "[%key:common::action::turn_off%]"
"name": "Turn off media player"
},
"turn_on": {
"description": "Turns on the power of a media player.",
"name": "[%key:common::action::turn_on%]"
"name": "Turn on media player"
},
"unjoin": {
"description": "Removes a media player from a group. Only works on platforms which support player groups.",
"name": "Unjoin"
"name": "Unjoin media player"
},
"volume_down": {
"description": "Turns down the volume of a media player.",
"name": "Turn down volume"
"name": "Turn down media player volume"
},
"volume_mute": {
"description": "Mutes or unmutes a media player.",
@@ -414,7 +414,7 @@
"name": "Muted"
}
},
"name": "Mute/unmute volume"
"name": "Mute/unmute media player"
},
"volume_set": {
"description": "Sets the volume level of a media player.",
@@ -424,11 +424,11 @@
"name": "Level"
}
},
"name": "Set volume"
"name": "Set media player volume"
},
"volume_up": {
"description": "Turns up the volume of a media player.",
"name": "Turn up volume"
"name": "Turn up media player volume"
}
},
"title": "Media player",

View File

@@ -2,32 +2,26 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import MutesyncUpdateCoordinator
from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> bool:
"""Set up mütesync from a config entry."""
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
MutesyncUpdateCoordinator(hass, entry)
)
coordinator = MutesyncUpdateCoordinator(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: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,14 +1,13 @@
"""mütesync binary sensor entities."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MutesyncUpdateCoordinator
from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator
SENSORS = (
"in_meeting",
@@ -18,11 +17,11 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MutesyncConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the mütesync button."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
"""Set up the mütesync binary sensors."""
coordinator = config_entry.runtime_data
async_add_entities(
[MuteStatus(coordinator, sensor_type) for sensor_type in SENSORS], True
)

View File

@@ -15,18 +15,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, UPDATE_INTERVAL_IN_MEETING, UPDATE_INTERVAL_NOT_IN_MEETING
type MutesyncConfigEntry = ConfigEntry[MutesyncUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class MutesyncUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for the mütesync integration."""
config_entry: ConfigEntry
config_entry: MutesyncConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MutesyncConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(

View File

@@ -34,6 +34,7 @@ from .const import (
EVENT_TYPE_OFF,
EVENT_TYPE_ON,
MANUFACTURER,
NETATMO_ALIM_STATUS_ONLINE,
NETATMO_CREATE_CAMERA,
SERVICE_SET_CAMERA_LIGHT,
SERVICE_SET_PERSON_AWAY,
@@ -174,18 +175,16 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
self._monitoring = False
elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]:
_LOGGER.debug(
"Camera %s has received %s event, turning on and enabling streaming",
"Camera %s has received %s event, turning on and enabling streaming if applicable",
data["camera_id"],
event_type,
)
self._attr_is_streaming = True
if self.device_type != "NDB":
self._attr_is_streaming = True
self._monitoring = True
elif event_type == EVENT_TYPE_LIGHT_MODE:
if data.get("sub_type"):
self._light_state = data["sub_type"]
self._attr_extra_state_attributes.update(
{"light_state": self._light_state}
)
else:
_LOGGER.debug(
"Camera %s has received light mode event without sub_type",
@@ -225,6 +224,20 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
supported_features |= CameraEntityFeature.STREAM
return supported_features
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
return {
"id": self.device.entity_id,
"monitoring": self._monitoring,
"sd_status": self.device.sd_status,
"alim_status": self.device.alim_status,
"is_local": self.device.is_local,
"vpn_url": self.device.vpn_url,
"local_url": self.device.local_url,
"light_state": self._light_state,
}
async def async_turn_off(self) -> None:
"""Turn off camera."""
await self.device.async_monitoring_off()
@@ -248,7 +261,10 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
self._attr_is_on = self.device.alim_status is not None
self._attr_available = self.device.alim_status is not None
if self.device.monitoring is not None:
if self.device_type == "NDB":
self._monitoring = self.device.alim_status == NETATMO_ALIM_STATUS_ONLINE
elif self.device.monitoring is not None:
self._monitoring = self.device.monitoring
self._attr_is_streaming = self.device.monitoring
self._attr_motion_detection_enabled = self.device.monitoring
@@ -256,19 +272,6 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
self.process_events(self.device.events)
)
self._attr_extra_state_attributes.update(
{
"id": self.device.entity_id,
"monitoring": self._monitoring,
"sd_status": self.device.sd_status,
"alim_status": self.device.alim_status,
"is_local": self.device.is_local,
"vpn_url": self.device.vpn_url,
"local_url": self.device.local_url,
"light_state": self._light_state,
}
)
def process_events(self, event_list: list[NaEvent]) -> dict:
"""Add meta data to events."""
events = {}

View File

@@ -215,5 +215,15 @@ WEBHOOK_ACTIVATION = "webhook_activation"
WEBHOOK_DEACTIVATION = "webhook_deactivation"
WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection"
WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection"
WEBHOOK_NDB_CONNECTION = "NDB-connection"
WEBHOOK_PUSH_TYPE = "push_type"
CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION]
CAMERA_CONNECTION_WEBHOOKS = [
WEBHOOK_NACAMERA_CONNECTION,
WEBHOOK_NOCAMERA_CONNECTION,
WEBHOOK_NDB_CONNECTION,
]
# Alimentation status (alim_status) for cameras and door bells (NDB).
# For NDB there is no monitoring attribute in status but only alim_status.
# 2 = Full power/online for NDB (and also Correct power adapter for NACamera).
NETATMO_ALIM_STATUS_ONLINE = 2

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