Compare commits

..

160 Commits

Author SHA1 Message Date
Ludovic BOUÉ
0a8a1ff345 Update test_sensor.ambr to use list for aliases and add object_id_base for last change attributes 2026-03-19 22:50:10 +01:00
Ludovic BOUÉ
43b2e26993 Refactor device_to_ha mapping to use get method for SetpointChangeSource 2026-03-19 22:44:30 +01:00
Ludovic BOUÉ
68bea745d4 Merge branch 'dev' into setpoint_change_source 2026-03-19 22:35:53 +01:00
Hai-Nam Nguyen
21d06fdace Fix unit when plant power is above 1000W in Hypontech (#165959)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-19 21:52:59 +01:00
AlCalzone
c8cf13ba19 Do not use moving states for Multilevel Switch CC v1-3 Z-Wave covers (#165909) 2026-03-19 21:30:26 +01:00
johanzander
d9a29bd486 growatt_server: add translation keys to all raised exceptions (#165927)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-19 21:08:15 +01:00
Norbert Rittel
bd0145cb8d Fix spelling of "Wi-Fi" trademark in user-facing string of sfr_box (#166019) 2026-03-19 20:43:16 +01:00
wollew
d002b48335 Replace deprecated library call in Velux integration (#165996)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 19:29:35 +01:00
Norbert Rittel
c66daf13d3 Fix spelling of "Wi-Fi" in user-facing strings of shelly (#166017) 2026-03-19 19:17:23 +01:00
Christian Lackas
1cae0e3cd3 Bump homematicip to 2.7.0 (#166012) 2026-03-19 17:53:12 +00:00
Paul Tarjan
de93d1d52a Skip unmapped and watchdog event types in Hikvision NVR event injection (#165009)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 18:39:28 +01:00
Tucker Kern
c67438c515 Snapcast: Fix incorrect identifier extraction in async_join_players (#165020) 2026-03-19 18:36:42 +01:00
Linkplay2020
fa57f72f37 Add WiiM media player integration (#148948)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-19 18:33:53 +01:00
Tom Matheussen
29309d1315 Add reconfigure flow to Satel Integra (#164938)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-19 18:31:46 +01:00
Robin Lintermann
130e0db742 Change codeowner of smarla integration (#166015) 2026-03-19 18:30:24 +01:00
Willem-Jan van Rootselaar
450d46f652 Fix optional static values in bsblan (#165488) 2026-03-19 17:07:49 +00:00
DeerMaximum
625603839c Remove DeerMaximum from velux codeowners (#166014) 2026-03-19 17:39:55 +01:00
Michael Hansen
fb66d766a8 Ensure STT metadata enums are passed (#165220) 2026-03-19 17:38:43 +01:00
Paul Bottein
e5f13b4126 Add state_attr_translated template filter and function (#165317)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-19 17:21:43 +01:00
Raj Laud
4a22f2c93e Add reauth flow and auto-trigger to victron_ble integration (#165729)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 17:01:04 +01:00
Mike Degatano
a5c48b190a Remove get_issues_info from hassio __all__ (#165929) 2026-03-19 16:58:20 +01:00
epenet
5e1a0e2152 Use annotationlib.get_annotations in entity helper (#165331) 2026-03-19 15:27:42 +01:00
Hai-Nam Nguyen
9a5516bb1d Bump hyponcloud from 0.3.0 to 0.9.0 (#166005) 2026-03-19 15:25:44 +01:00
J. Diego Rodríguez Royo
b9172cf4a8 Add 3D heating, air fry, and grill programs to Home Connect (#166006) 2026-03-19 15:21:20 +01:00
Ariel Ebersberger
8e4dc29226 Fix backblaze_b2 tests for Python 3.14.3 (#165930) 2026-03-19 14:01:27 +01:00
epenet
b152f2f9a6 Add test fixture for Tuya WiFi smart online 8 in 1 tester (#166003) 2026-03-19 13:27:42 +01:00
epenet
abca80dc13 Simplify mocking of Tuya device notifications (#165998) 2026-03-19 13:24:10 +01:00
Ville Skyttä
6869369ab2 Add some EZVIZ sensor icons (#166000) 2026-03-19 13:23:33 +01:00
Brett Adams
c2dde06713 Fix mixed-language Splunk setup errors in exception translations (#165974) 2026-03-19 13:21:45 +01:00
Retha Runolfsson
e455c05721 Added exception handling when switchbot account login. (#165978) 2026-03-19 13:15:45 +01:00
Ariel Ebersberger
085df1de19 Fix Home Asssitant Cloud test for Python 3.14.3 (#165937) 2026-03-19 12:11:26 +00:00
J. Diego Rodríguez Royo
91a1237965 Bump aiohomeconnect to 0.33.0 (#166001) 2026-03-19 13:07:57 +01:00
Raj Laud
680a6bc856 Add sensor tests for missing victron_ble device types (#165498)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 12:06:14 +01:00
dependabot[bot]
152912c258 Bump actions/download-artifact from 8.0.0 to 8.0.1 (#165982)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 11:20:30 +01:00
wollew
40e8a1b11a Bump pyvlx to 0.2.32 (#165990) 2026-03-19 11:14:57 +01:00
johanzander
69dc354669 growatt_server: add diagnostics support (#165923)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 11:09:25 +01:00
Christopher Fenner
bbe1bf14ae Bump PyViCare to 2.58.1 (#165965) 2026-03-19 10:19:48 +01:00
Joost Lekkerkerker
5470d8f8a7 Add switch for microfilter bypass mode to SmartThings (#165919) 2026-03-19 09:29:48 +01:00
Joost Lekkerkerker
99fe4b10d0 Add sensors for microfilter to SmartThings (#165922) 2026-03-19 08:57:51 +01:00
Brett Adams
886b6b08ac Source Tessie phantom drain and battery sensors from state data (#165970) 2026-03-19 08:24:32 +01:00
Robert Svensson
6a1e7c1cca Switch over to aiohttp on the Axis integration (#165963) 2026-03-19 08:23:06 +01:00
Josef Zweck
d17df13055 Manually update values instead of sending an event in mold_indicator (#165891) 2026-03-19 08:17:07 +01:00
J. Nick Koston
f73502c77a Bump ulid-transform to 2.2.0 (#165964) 2026-03-18 23:15:26 +01:00
Dan Raper
2c37a86bc9 Bump ohme to 1.7.1 (#165951) 2026-03-18 21:47:48 +00:00
tronikos
fa8e976de7 Add exception translations to Google Weather (#165935)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:25:58 -07:00
Andres Ruiz
877bca28ad Stop manually assigning an entity_id in waterfurnace sensors (#165954) 2026-03-18 20:58:36 +01:00
tronikos
a57c65f512 Add reconfigure flow in Google Drive (#165926) 2026-03-18 12:46:43 -07:00
tronikos
7140826dbb Do not abbreviate "reauthentication" in Google Drive (#165941) 2026-03-18 20:38:49 +01:00
Bouwe Westerdijk
5fea8d69d7 Add live firmware update detection to Plugwise (#165936)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 20:37:57 +01:00
Paul Tarjan
98e3b9962e Log Withings webhook URL warning only once (#164551)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 20:21:38 +01:00
Kurt Chrisford
afe19147f8 Test coverage for the Actron Air integration (#164446) 2026-03-18 20:20:51 +01:00
Willem-Jan van Rootselaar
0e7c25488c Add reconfigure flow to BSB-LAN (#164070) 2026-03-18 20:19:50 +01:00
Jan Čermák
412e85203d Add issue and repair for NTP sync failure (#165463)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-18 20:16:46 +01:00
Abílio Costa
55ec4a95fd Update renault snapshots (#165948) 2026-03-18 19:59:39 +01:00
Artur Pragacz
6ea9e9a161 Remove targets from intent response (#165434)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 18:35:30 +00:00
tronikos
b56e6d1ff7 Update Google Drive quality scale rules to match #156167 (#165916) 2026-03-18 19:34:55 +01:00
Eduardo Tsen
b502cdd15b Add buttons for controlling dishwasher operation (#160269)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-18 19:32:58 +01:00
Mike Ryan
b7ba85192d Add Trigger Motion Activity button to fully kiosk browser (#164499)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 14:24:51 -04:00
Erik Montnemery
04d45c8ada Add schedule conditions (#165913)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-18 18:55:47 +01:00
tronikos
ba0804fefa Add exception translations to Google Drive (#165932) 2026-03-18 18:51:07 +01:00
Erik Montnemery
538b817bf1 Adjust inheritance tree of EntitySelectorConfig (#165915) 2026-03-18 18:32:44 +01:00
Brandon Rothweiler
7efa2d3cac Add Dropbox backup integration (#155644) 2026-03-18 17:58:57 +01:00
Erik Montnemery
3f872fd196 Allow specifying attribute in state selector (#165928) 2026-03-18 17:54:36 +01:00
Erik Montnemery
b00f6593f1 Add unit of measurement to entity selector filter (#165914) 2026-03-18 17:01:21 +01:00
Raj Laud
a63516ff71 Allow retry on invalid encryption key in victron_ble config flow (#165600)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 15:53:03 +01:00
Joost Lekkerkerker
55b082edb6 Add binary sensor for smartthings microfilter blockage (#165917) 2026-03-18 15:44:43 +01:00
Robert Resch
b0c3ede4fd Improve type hints for startca (#165720) 2026-03-18 15:43:50 +01:00
johanzander
84bd1cd336 growatt_server: use icon-translations instead of hardcoded _attr_icon (#165920)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:41:48 +01:00
Erik Montnemery
25bbfcc595 Add gate conditions (#165898) 2026-03-18 15:27:27 +01:00
johanzander
bf05925c8b growatt_server: replace custom precision with suggested_display_precision (#165858)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:20:30 +01:00
Jan Čermák
488d9ad75c Use new home-assistant/builder actions for image builds (#164756) 2026-03-18 14:44:53 +01:00
Vincent Le Ligeour
2dfad3d755 Add battery charge limit controls to Renault (#163079)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-03-18 14:29:36 +01:00
Stefan Agner
7e759bf730 Fix Abort exception caught by wrong handler in backup encrypt/decrypt (#165852)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:28:56 +01:00
Robert Svensson
9678049e72 Bump axis to v67 (#165840) 2026-03-18 14:25:54 +01:00
David Bonnes
8602ba2679 Extend Evohome tests to cover legacy service calls (#164316) 2026-03-18 13:57:31 +01:00
Paulus Schoutsen
78c3503b7d Remove unnecessary volume_up/volume_down overrides from ws66i media player (#164433)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:56:22 +01:00
Paulus Schoutsen
fbb3b81991 Remove unnecessary volume_up/volume_down overrides from songpal media player (#164432)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:56:04 +01:00
Paulus Schoutsen
26eaf510ee Remove unnecessary volume_up/volume_down overrides from clementine media player (#164427)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 13:55:50 +01:00
Erik Montnemery
5c83d16995 Add select triggers (#165378)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:55:02 +01:00
Joost Lekkerkerker
388b258d6c Add Zinvolt problem binary sensors (#164091) 2026-03-18 13:54:42 +01:00
Jeef
2c9a5c10da Add data-description strings to IntelliFire (#165910)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:49:04 +01:00
Erik Montnemery
5a68bafd69 Add garage_door conditions (#165897) 2026-03-18 13:29:13 +01:00
Erik Montnemery
33fce89a2b Add window conditions (#165899) 2026-03-18 13:16:11 +01:00
Erwin Douna
1932f61da3 Proxmox fix restart/reboot action (#165901) 2026-03-18 11:55:51 +01:00
Mike Degatano
5a231b27b9 Add repair for deprecated arch addon issue (#165511) 2026-03-18 11:53:09 +01:00
Steve Easley
5617e8c7bc Move jvc_projector sensor entities to select domain (#165194) 2026-03-18 11:34:03 +01:00
Emil Burzo
2b5b0e9d0f Add battery temperature sensor to Fully Kiosk Browser integration (#165714) 2026-03-18 11:22:25 +01:00
Josef Zweck
732f553b48 Safely consume events in hassio test (#165892) 2026-03-18 10:43:20 +01:00
Erik Montnemery
0a53b227ed Add door conditions (#165885) 2026-03-18 10:19:06 +01:00
Erik Montnemery
44b73ab7bd Add occupancy conditions (#165678) 2026-03-18 09:43:17 +01:00
Erik Montnemery
538061d512 Add motion conditions (#165677) 2026-03-18 09:23:32 +01:00
Raj Laud
e307ceccb5 Bump victron-ble-ha-parser to 0.6.2 (#165832)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 08:47:34 +01:00
Erik Montnemery
ea7558c0ad Improve naming in condition and trigger test helpers (#165847)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 08:44:14 +01:00
johanzander
c4399b5547 growatt_server: add serial_number to DeviceInfo (devices quality scale rule) (#165857)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:42:24 +01:00
Erwin Douna
d989a83d7b Remove NotImplementedError in Volvo integration (#165856) 2026-03-18 08:41:35 +01:00
mettolen
d04f3530df Remove the icon property from Huum climate entity (#165870) 2026-03-18 08:28:02 +01:00
mettolen
647d957ffe Removed redundant logging from Huum integration (#165868) 2026-03-18 08:27:13 +01:00
johanzander
a3f3c87b39 growatt_server: add EntityCategory.DIAGNOSTIC to diagnostic sensors (#165880)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 08:24:49 +01:00
Nathan Spencer
447b17a2a4 Bump pyweatherflowudp to 1.5.2 (#165874) 2026-03-18 08:24:24 +01:00
Joost Lekkerkerker
eb2b92687c Add camera fixture to SmartThings (#165809) 2026-03-18 08:23:44 +01:00
Jack Boswell
6424e3658e Remove myself from Starlink codeowners (#165883) 2026-03-18 08:22:18 +01:00
Erik Montnemery
d1d8754853 Fix type annotations for set_or_remove_state test helper (#165843) 2026-03-18 08:03:45 +01:00
Erik Montnemery
c4ff7fa676 Fix bug in assert_condition_behavior_any test helper (#165838) 2026-03-18 08:03:18 +01:00
balloob-travel
f1fe1d3956 Update config flow testing instructions for AI (#165873)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-03-18 06:39:15 +01:00
Christopher Fenner
fd0d60b787 Fix return type in ViCare integration (#165861) 2026-03-18 03:12:06 +01:00
Stefan Agner
9ddefaaacd Bump aiohasupervisor to 0.4.2 (#165854) 2026-03-17 23:08:57 +01:00
Ludovic BOUÉ
5c8df048b1 Fix timezone in account creation date in test snapshot (#165831) 2026-03-17 22:53:36 +01:00
Raj Laud
d86d85ec56 Fix victron_ble charger error sensor always showing unknown (#165713)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 21:45:51 +00:00
tronikos
660f12b683 Implement dynamic-devices and stale-devices in Opower to mark it platinum (#165121)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 22:43:57 +01:00
Jan Bouwhuis
b8238c86e6 Cleanup unused vacuum test helpers (#165851) 2026-03-17 22:36:24 +01:00
Raman Gupta
754828188e Refactor Vizio integration to use DataUpdateCoordinator (#162188)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:20:01 -04:00
Erik Montnemery
6992a3c72b Adjust name and docstring of some trigger tests (#165846) 2026-03-17 22:11:32 +01:00
Joost Lekkerkerker
738d4f662a Bump pySmartThings to 3.7.2 (#165810) 2026-03-17 21:57:20 +01:00
Carlos Sánchez López
7f33ac72ab Add alarm control panel support for Tuya WG2 alarm panel (Duosmart C30) (#165837) 2026-03-17 21:44:57 +01:00
Carlos Sánchez López
0891d814fa Add sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165834) 2026-03-17 21:42:20 +01:00
Carlos Sánchez López
ddab50edcc Add binary sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165833) 2026-03-17 21:41:57 +01:00
Erik Montnemery
c8ce4eb32d Deduplicate tests testing conditions in mode all (#165841) 2026-03-17 21:06:26 +01:00
Jan Bouwhuis
22aca8b7af Add clean segment support to MQTT vacuum entities (#164983)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-17 20:27:42 +01:00
Erik Montnemery
770864082f Deduplicate tests testing conditions in mode any (#165801) 2026-03-17 19:23:47 +00:00
Abílio Costa
14545660e2 Make TODO subscriptions use TodoItem instead of JSON (#165802) 2026-03-17 19:09:13 +00:00
Jamie Magee
836353015b Detect new garage doors automatically in aladdin_connect (#165004) 2026-03-17 20:04:31 +01:00
Allen Porter
c57ffd4d78 Update python-roborock dependency to 4.25.0. (#165800)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-03-17 19:58:18 +01:00
prana-dev-official
cbebfdf149 Add number platform for Prana integration (#165816) 2026-03-17 19:53:50 +01:00
Ludovic BOUÉ
d8ed9ca66f Fix timestamps in chess_com test diagnostics (#165829) 2026-03-17 19:30:08 +01:00
Cody
5caf8a5b83 Make Season integration timezone aware (#164876) 2026-03-17 18:09:25 +01:00
Aidan Timson
c05210683e Demo valve registry entry and device (#165803)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 17:37:21 +01:00
Joost Lekkerkerker
aa8dd4bb66 Add microfiber filter fixture to SmartThings (#165808) 2026-03-17 17:21:51 +01:00
Joost Lekkerkerker
ee7d6157d9 Fix Indevolt button snapshot (#165812) 2026-03-17 17:19:03 +01:00
Manu
adec1d128c Add exception handling to media source in Radio Browser integration (#164653) 2026-03-17 17:13:11 +01:00
Ludovic BOUÉ
9f876757f6 Merge branch 'dev' into setpoint_change_source 2026-03-11 19:30:04 +01:00
Ludovic BOUÉ
28f70fab8d Add setpoint_change_source icon with states for external, manual, and schedule 2026-01-13 20:23:07 +00:00
Ludovic BOUÉ
289490faa3 Refactor setpoint_change_timestamp device_to_ha conversion to use matter_epoch_seconds_to_utc 2026-01-12 20:57:39 +00:00
Ludovic BOUÉ
dea46f7b2e Add tests for Eve Thermo v5 SetpointChangeSource, timestamp, and amount sensors 2026-01-12 20:54:52 +00:00
Ludovic BOUÉ
82e3221126 Update Matter Eve Thermo sensor entries to reflect last change and change amount attributes 2026-01-12 19:28:15 +00:00
Ludovic BOUÉ
47e8fbc1ed Add Matter Eve Thermo 20ECD1701 sensor entries and update mock thermostat configurations 2026-01-12 19:25:10 +00:00
Ludovic BOUÉ
0428d0b97f Merge branch 'dev' into setpoint_change_source 2026-01-12 20:19:09 +01:00
Ludovic BOUÉ
45344c04c1 Refactor setpoint change source mapping and add utility functions for Matter epoch conversion 2026-01-12 19:14:54 +00:00
Ludovic BOUÉ
c472b6ac5e Add support for RoomAirConditioner device type 2025-12-01 15:12:33 +00:00
Ludovic BOUÉ
58f533feb6 Add device_type attribute for Thermostat sensors 2025-11-30 21:43:43 +01:00
Ludovic BOUÉ
0af8c8fd8c Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 21:37:01 +01:00
Ludovic BOUÉ
b9d6c3b9fe Update homeassistant/components/matter/strings.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 21:36:08 +01:00
Ludovic BOUÉ
b700940bb9 Merge branch 'dev' into setpoint_change_source 2025-11-30 21:31:19 +01:00
Ludovic BOUÉ
3b73f6d37e Update thermostat setpoint change timestamp to January 1, 2025 2025-11-30 20:21:45 +00:00
Ludovic BOUÉ
2812bb21da Add offset for Matter 2000 epoch in timestamp conversion 2025-11-30 20:20:13 +00:00
Ludovic BOUÉ
5d474675e8 Update mock thermostat state timestamp to January 1, 2025 2025-11-30 20:15:31 +00:00
Ludovic BOUÉ
ea7bcf6cda Update mock thermostat JSON to correct timestamp for attribute 1/513/50 2025-11-30 20:11:19 +00:00
Ludovic BOUÉ
725bd3d671 Add mock thermostat entity and state snapshots for temperature display mode 2025-11-21 12:38:04 +00:00
Ludovic BOUÉ
cfc4fa6342 Merge branch 'dev' into setpoint_change_source 2025-11-21 13:35:31 +01:00
Ludovic BOUÉ
b650e71660 Update mock thermostat snapshots with new attributes and state values 2025-11-18 18:05:29 +00:00
Ludovic BOUÉ
9ddf15e348 Update mock thermostat JSON with additional attributes and values 2025-11-18 18:03:46 +00:00
Ludovic BOUÉ
15082f9111 Merge branch 'dev' into setpoint_change_source 2025-11-18 16:45:05 +01:00
Ludovic BOUÉ
12f16611ff Rename mock thermostat entity IDs and friendly names in snapshots for consistency 2025-11-18 15:30:39 +00:00
Ludovic BOUÉ
8041be3d08 Merge branch 'dev' into setpoint_change_source 2025-11-18 14:08:38 +01:00
Ludovic BOUÉ
40b021e755 Add tests for Thermostat SetpointChangeSource, Timestamp, and Amount sensors 2025-11-18 13:02:44 +00:00
Ludovic BOUÉ
aab57eda96 Update mock thermostat product name to "Mock Thermostat" 2025-11-18 13:00:26 +00:00
Ludovic BOUÉ
f0dd37caa5 Add mock thermostat sensors and states for testing 2025-11-18 12:49:32 +00:00
Ludovic BOUÉ
662b178495 Remove unused attribute from thermostat fixture 2025-11-18 12:48:56 +00:00
Ludovic BOUÉ
cb3d30884a Add mock thermostat fixture for integration tests 2025-11-18 12:48:21 +00:00
Ludovic BOUÉ
49e6f20372 Add Setpoint Change Source timestamp and amount sensors with localization strings 2025-11-18 12:39:28 +00:00
Ludovic BOUÉ
75d02661eb Add Setpoint Change Source sensor and localization strings 2025-11-14 17:19:28 +00:00
440 changed files with 21898 additions and 4764 deletions

View File

@@ -620,12 +620,14 @@ rules:
### Config Flow Testing ### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested - **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**: - **Test Scenarios**:
- All flow initiation methods (user, discovery, import) - All flow initiation methods (user, discovery, import)
- Successful configuration paths - Successful configuration paths
- Error recovery scenarios - Error recovery scenarios
- Prevention of duplicate entries - Prevention of duplicate entries
- Flow completion after errors - Flow completion after errors
- Reauthentication/reconfigure flows
### Testing ### Testing
- **Integration-specific tests** (recommended): - **Integration-specific tests** (recommended):

1
.gitattributes vendored
View File

@@ -16,6 +16,7 @@ Dockerfile.dev linguist-language=Dockerfile
CODEOWNERS linguist-generated=true CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true homeassistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true mypy.ini linguist-generated=true
requirements.txt linguist-generated=true requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true requirements_all.txt linguist-generated=true

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker # Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0" BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]' ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {} permissions: {}
@@ -35,6 +35,7 @@ jobs:
channel: ${{ steps.version.outputs.channel }} channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }} architectures: ${{ env.ARCHITECTURES }}
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -100,7 +101,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
include: include:
- arch: amd64 - arch: amd64
os: ubuntu-latest os: ubuntu-24.04
- arch: aarch64 - arch: aarch64
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
steps: steps:
@@ -181,7 +182,7 @@ jobs:
fi fi
- name: Download translations - name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: translations name: translations
@@ -195,77 +196,20 @@ jobs:
run: | run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
with:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build variables
id: vars
shell: bash
env:
ARCH: ${{ matrix.arch }}
run: |
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
env:
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${BASE_IMAGE}"
- name: Verify cache image signature
id: cache
continue-on-error: true
env:
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${CACHE_IMAGE}"
- name: Build base image - name: Build base image
id: build uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . arch: ${{ matrix.arch }}
file: ./Dockerfile
platforms: ${{ steps.vars.outputs.platform }}
push: true
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
build-args: | build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }} BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }} cache-gha: false
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true container-registry-password: ${{ secrets.GITHUB_TOKEN }}
labels: | cosign-base-identity: "https://github.com/home-assistant/docker/.*"
io.hass.arch=${{ matrix.arch }} cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
io.hass.version=${{ needs.init.outputs.version }} image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
org.opencontainers.image.created=${{ steps.vars.outputs.created }} image-tags: ${{ needs.init.outputs.version }}
org.opencontainers.image.version=${{ needs.init.outputs.version }} push: true
version: ${{ needs.init.outputs.version }}
- name: Sign image
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.init.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
build_machine: build_machine:
name: Build ${{ matrix.machine }} machine core image name: Build ${{ matrix.machine }} machine core image
@@ -314,35 +258,38 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Set build additional args - name: Compute extra tags
id: tags
shell: bash
env: env:
VERSION: ${{ needs.init.outputs.version }} VERSION: ${{ needs.init.outputs.version }}
run: | run: |
# Create general tags
if [[ "${VERSION}" =~ d ]]; then if [[ "${VERSION}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ b ]]; then elif [[ "${VERSION}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
else else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
fi fi
- name: Login to GitHub Container Registry - name: Build machine image
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with: with:
registry: ghcr.io arch: ${{ matrix.arch }}
username: ${{ github.repository_owner }} build-args: |
password: ${{ secrets.GITHUB_TOKEN }} BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
cache-gha: false
- name: Build base image container-registry-password: ${{ secrets.GITHUB_TOKEN }}
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1 context: machine/
with: cosign-base-identity: "https://github.com/home-assistant/core/.*"
image: ${{ matrix.arch }} cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
args: | file: machine/${{ matrix.machine }}
$BUILD_ARGS \ image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
--target /data/machine \ image-tags: |
--cosign \ ${{ needs.init.outputs.version }}
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" ${{ steps.tags.outputs.extra_tags }}
push: true
version: ${{ needs.init.outputs.version }}
publish_ha: publish_ha:
name: Publish version files name: Publish version files
@@ -543,7 +490,7 @@ jobs:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Download translations - name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: translations name: translations

View File

@@ -978,7 +978,7 @@ jobs:
run: | run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: pytest_buckets name: pytest_buckets
- name: Compile English translations - name: Compile English translations
@@ -1387,7 +1387,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@@ -1558,7 +1558,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
@@ -1587,7 +1587,7 @@ jobs:
&& needs.info.outputs.skip_coverage != 'true' && !cancelled() && needs.info.outputs.skip_coverage != 'true' && !cancelled()
steps: steps:
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
pattern: test-results-* pattern: test-results-*
- name: Upload test results to Codecov - name: Upload test results to Codecov

View File

@@ -121,12 +121,12 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: requirements_diff name: requirements_diff
@@ -172,17 +172,17 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: requirements_all_wheels name: requirements_all_wheels

View File

@@ -1 +1 @@
3.14.3 3.14.2

View File

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

14
CODEOWNERS generated
View File

@@ -397,6 +397,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dropbox/ @bdr99
/tests/components/dropbox/ @bdr99
/homeassistant/components/droplet/ @sarahseidman /homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221 /homeassistant/components/dsmr/ @Robbie1221
@@ -1561,8 +1563,8 @@ build.json @home-assistant/supervisor
/tests/components/sma/ @kellerza @rklomp @erwindouna /tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee /homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee
/homeassistant/components/smarla/ @explicatis @rlint-explicatis /homeassistant/components/smarla/ @explicatis @johannes-exp
/tests/components/smarla/ @explicatis @rlint-explicatis /tests/components/smarla/ @explicatis @johannes-exp
/homeassistant/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek /homeassistant/components/smartthings/ @joostlek
@@ -1616,8 +1618,6 @@ build.json @home-assistant/supervisor
/tests/components/srp_energy/ @briglx /tests/components/srp_energy/ @briglx
/homeassistant/components/starline/ @anonym-tsk /homeassistant/components/starline/ @anonym-tsk
/tests/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk
/homeassistant/components/starlink/ @boswelja
/tests/components/starlink/ @boswelja
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
/tests/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST
/homeassistant/components/steam_online/ @tkdrob /homeassistant/components/steam_online/ @tkdrob
@@ -1831,8 +1831,8 @@ build.json @home-assistant/supervisor
/tests/components/vegehub/ @thulrus /tests/components/vegehub/ @thulrus
/homeassistant/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/velux/ @Julius2342 @pawlizio @wollew
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /tests/components/velux/ @Julius2342 @pawlizio @wollew
/homeassistant/components/venstar/ @garbled1 @jhollowe /homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/versasense/ @imstevenxyz
@@ -1915,6 +1915,8 @@ build.json @home-assistant/supervisor
/tests/components/whois/ @frenck /tests/components/whois/ @frenck
/homeassistant/components/wiffi/ @mampfes /homeassistant/components/wiffi/ @mampfes
/tests/components/wiffi/ @mampfes /tests/components/wiffi/ @mampfes
/homeassistant/components/wiim/ @Linkplay2020
/tests/components/wiim/ @Linkplay2020
/homeassistant/components/wilight/ @leofig-rj /homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core /homeassistant/components/window/ @home-assistant/core

1
Dockerfile generated
View File

@@ -10,7 +10,6 @@ LABEL \
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.source="https://github.com/home-assistant/core" \
org.opencontainers.image.title="Home Assistant" \ org.opencontainers.image.title="Home Assistant" \
org.opencontainers.image.url="https://www.home-assistant.io/" org.opencontainers.image.url="https://www.home-assistant.io/"

View File

@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="timeout", step_id="timeout",
) )
del self.login_task self.login_task = None
return await self.async_step_user() return await self.async_step_user()
async def async_step_reauth( async def async_step_reauth(

View File

@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/actron_air", "documentation": "https://www.home-assistant.io/integrations/actron_air",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "bronze", "quality_scale": "silver",
"requirements": ["actron-neo-api==0.4.1"] "requirements": ["actron-neo-api==0.4.1"]
} }

View File

@@ -37,7 +37,7 @@ rules:
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: done reauthentication-flow: done
test-coverage: todo test-coverage: done
# Gold # Gold
devices: done devices: done

View File

@@ -46,19 +46,10 @@ async def async_setup_entry(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
) )
try: coordinator = AladdinConnectCoordinator(hass, entry, client)
doors = await client.get_doors() await coordinator.async_config_entry_first_refresh()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = { entry.runtime_data = coordinator
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
for door in doors
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -100,7 +91,7 @@ def remove_stale_devices(
device_entries = dr.async_entries_for_config_entry( device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id device_registry, config_entry.entry_id
) )
all_device_ids = set(config_entry.runtime_data) all_device_ids = set(config_entry.runtime_data.data)
for device_entry in device_entries: for device_entry in device_entries:
device_id: str | None = None device_id: str | None = None

View File

@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]] type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
SCAN_INTERVAL = timedelta(seconds=15) SCAN_INTERVAL = timedelta(seconds=15)
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]): class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
"""Coordinator for Aladdin Connect integration.""" """Coordinator for Aladdin Connect integration."""
config_entry: AladdinConnectConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
entry: AladdinConnectConfigEntry, entry: AladdinConnectConfigEntry,
client: AladdinConnectClient, client: AladdinConnectClient,
garage_door: GarageDoor,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
self.client = client self.client = client
self.data = garage_door
async def _async_update_data(self) -> GarageDoor: async def _async_update_data(self) -> dict[str, GarageDoor]:
"""Fetch data from the Aladdin Connect API.""" """Fetch data from the Aladdin Connect API."""
try: try:
await self.client.update_door(self.data.device_id, self.data.door_number) doors = await self.client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(f"Error communicating with API: {err}") from err
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err raise UpdateFailed(f"Error communicating with API: {err}") from err
self.data.status = self.client.get_door_status(
self.data.device_id, self.data.door_number return {door.unique_id: door for door in doors}
)
self.data.battery_level = self.client.get_battery_status(
self.data.device_id, self.data.door_number
)
return self.data

View File

@@ -7,7 +7,7 @@ from typing import Any
import aiohttp import aiohttp
from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,11 +24,22 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the cover platform.""" """Set up the cover platform."""
coordinators = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities( @callback
AladdinCoverEntity(coordinator) for coordinator in coordinators.values() def _async_add_new_devices() -> None:
) """Detect and add entities for new doors."""
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
_attr_supported_features = SUPPORTED_FEATURES _attr_supported_features = SUPPORTED_FEATURES
_attr_name = None _attr_name = None
def __init__(self, coordinator: AladdinConnectCoordinator) -> None: def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize the Aladdin Connect cover.""" """Initialize the Aladdin Connect cover."""
super().__init__(coordinator) super().__init__(coordinator, door_id)
self._attr_unique_id = coordinator.data.unique_id self._attr_unique_id = door_id
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover.""" """Issue open command to cover."""
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@property @property
def is_closed(self) -> bool | None: def is_closed(self) -> bool | None:
"""Update is closed attribute.""" """Update is closed attribute."""
if (status := self.coordinator.data.status) is None: if (status := self.door.status) is None:
return None return None
return status == "closed" return status == "closed"
@property @property
def is_closing(self) -> bool | None: def is_closing(self) -> bool | None:
"""Update is closing attribute.""" """Update is closing attribute."""
return self.coordinator.data.status == "closing" return self.door.status == "closing"
@property @property
def is_opening(self) -> bool | None: def is_opening(self) -> bool | None:
"""Update is opening attribute.""" """Update is opening attribute."""
return self.coordinator.data.status == "opening" return self.door.status == "opening"

View File

@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"doors": { "doors": {
uid: { uid: {
"device_id": coordinator.data.device_id, "device_id": door.device_id,
"door_number": coordinator.data.door_number, "door_number": door.door_number,
"name": coordinator.data.name, "name": door.name,
"status": coordinator.data.status, "status": door.status,
"link_status": coordinator.data.link_status, "link_status": door.link_status,
"battery_level": coordinator.data.battery_level, "battery_level": door.battery_level,
} }
for uid, coordinator in config_entry.runtime_data.items() for uid, door in config_entry.runtime_data.data.items()
}, },
} }

View File

@@ -1,6 +1,7 @@
"""Base class for Aladdin Connect entities.""" """Base class for Aladdin Connect entities."""
from genie_partner_sdk.client import AladdinConnectClient from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, coordinator: AladdinConnectCoordinator) -> None: def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize Aladdin Connect entity.""" """Initialize Aladdin Connect entity."""
super().__init__(coordinator) super().__init__(coordinator)
device = coordinator.data self._door_id = door_id
door = self.door
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)}, identifiers={(DOMAIN, door.unique_id)},
manufacturer="Aladdin Connect", manufacturer="Aladdin Connect",
name=device.name, name=door.name,
) )
self._device_id = device.device_id self._device_id = door.device_id
self._number = device.door_number self._number = door.door_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._door_id in self.coordinator.data
@property
def door(self) -> GarageDoor:
"""Return the garage door data."""
return self.coordinator.data[self._door_id]
@property @property
def client(self) -> AladdinConnectClient: def client(self) -> AladdinConnectClient:

View File

@@ -57,7 +57,7 @@ rules:
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: todo dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done

View File

@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
@@ -49,13 +49,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Aladdin Connect sensor devices.""" """Set up Aladdin Connect sensor devices."""
coordinators = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities( @callback
AladdinConnectSensor(coordinator, description) def _async_add_new_devices() -> None:
for coordinator in coordinators.values() """Detect and add entities for new doors."""
for description in SENSOR_TYPES current_devices = set(coordinator.data)
) new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinConnectSensor(coordinator, door_id, description)
for door_id in new_devices
for description in SENSOR_TYPES
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
def __init__( def __init__(
self, self,
coordinator: AladdinConnectCoordinator, coordinator: AladdinConnectCoordinator,
door_id: str,
entity_description: AladdinConnectSensorEntityDescription, entity_description: AladdinConnectSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the Aladdin Connect sensor.""" """Initialize the Aladdin Connect sensor."""
super().__init__(coordinator) super().__init__(coordinator, door_id)
self.entity_description = entity_description self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}" self._attr_unique_id = f"{door_id}-{entity_description.key}"
@property @property
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.door)

View File

@@ -123,16 +123,23 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"climate", "climate",
"cover", "cover",
"device_tracker", "device_tracker",
"door",
"fan", "fan",
"garage_door",
"gate",
"humidifier", "humidifier",
"lawn_mower", "lawn_mower",
"light", "light",
"lock", "lock",
"media_player", "media_player",
"motion",
"occupancy",
"person", "person",
"schedule",
"siren", "siren",
"switch", "switch",
"vacuum", "vacuum",
"window",
} }
_EXPERIMENTAL_TRIGGER_PLATFORMS = { _EXPERIMENTAL_TRIGGER_PLATFORMS = {
@@ -159,6 +166,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"remote", "remote",
"scene", "scene",
"schedule", "schedule",
"select",
"siren", "siren",
"switch", "switch",
"text", "text",

View File

@@ -15,7 +15,7 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.aiohttp_client import async_get_clientsession
from ..const import LOGGER from ..const import LOGGER
from ..errors import AuthenticationRequired, CannotConnect from ..errors import AuthenticationRequired, CannotConnect
@@ -26,7 +26,7 @@ async def get_axis_api(
config: Mapping[str, Any], config: Mapping[str, Any],
) -> axis.AxisDevice: ) -> axis.AxisDevice:
"""Create a Axis device API.""" """Create a Axis device API."""
session = get_async_client(hass, verify_ssl=False) session = async_get_clientsession(hass, verify_ssl=False)
api = axis.AxisDevice( api = axis.AxisDevice(
Configuration( Configuration(

View File

@@ -29,7 +29,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["axis"], "loggers": ["axis"],
"requirements": ["axis==66"], "requirements": ["axis==67"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "AXIS" "manufacturer": "AXIS"

View File

@@ -246,6 +246,8 @@ def decrypt_backup(
except (DecryptError, SecureTarError, tarfile.TarError) as err: except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err) LOGGER.warning("Error decrypting backup: %s", err)
error = err error = err
except Abort:
raise
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err) LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err error = err
@@ -332,8 +334,10 @@ def encrypt_backup(
except (EncryptError, SecureTarError, tarfile.TarError) as err: except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err) LOGGER.warning("Error encrypting backup: %s", err)
error = err error = err
except Abort:
raise
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err) LOGGER.exception("Unexpected error when encrypting backup: %s", err)
error = err error = err
else: else:
# Pad the output stream to the requested minimum size # Pad the output stream to the requested minimum size

View File

@@ -32,7 +32,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN from .const import CONF_PASSKEY, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services from .services import async_setup_services
@@ -52,7 +52,7 @@ class BSBLanData:
client: BSBLAN client: BSBLAN
device: Device device: Device
info: Info info: Info
static: StaticState static: StaticState | None
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -82,11 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# the connection by fetching firmware version # the connection by fetching firmware version
await bsblan.initialize() await bsblan.initialize()
# Fetch device metadata in parallel for faster startup # Fetch required device metadata in parallel for faster startup
device, info, static = await asyncio.gather( device, info = await asyncio.gather(
bsblan.device(), bsblan.device(),
bsblan.info(), bsblan.info(),
bsblan.static_values(),
) )
except BSBLANConnectionError as err: except BSBLANConnectionError as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
@@ -111,6 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
translation_key="setup_general_error", translation_key="setup_general_error",
) from err ) from err
try:
static = await bsblan.static_values()
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s: %s",
entry.data[CONF_HOST],
err,
)
static = None
# Create coordinators with the already-initialized client # Create coordinators with the already-initialized client
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan) fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan) slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)

View File

@@ -90,10 +90,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
# Set temperature range if available, otherwise use Home Assistant defaults # Set temperature range if available, otherwise use Home Assistant defaults
if data.static.min_temp is not None and data.static.min_temp.value is not None: if (static := data.static) is not None:
self._attr_min_temp = data.static.min_temp.value if (min_temp := static.min_temp) is not None and min_temp.value is not None:
if data.static.max_temp is not None and data.static.max_temp.value is not None: self._attr_min_temp = min_temp.value
self._attr_max_temp = data.static.max_temp.value if (max_temp := static.max_temp) is not None and max_temp.value is not None:
self._attr_max_temp = max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property @property

View File

@@ -183,90 +183,122 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
existing_entry = self._get_reauth_entry() existing_entry = self._get_reauth_entry()
if user_input is None: if user_input is None:
# Preserve existing values as defaults
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="reauth_confirm",
data_schema=vol.Schema( data_schema=self._build_credentials_schema(existing_entry.data),
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
) )
# Combine existing data with the user's new input for validation. # Merge existing data with user input for validation
# This correctly handles adding, changing, and clearing credentials. validate_data = {**existing_entry.data, **user_input}
config_data = existing_entry.data.copy() errors = await self._async_validate_credentials(validate_data)
config_data.update(user_input)
self.host = config_data[CONF_HOST] if errors:
self.port = config_data[CONF_PORT] return self.async_show_form(
self.passkey = config_data.get(CONF_PASSKEY) step_id="reauth_confirm",
self.username = config_data.get(CONF_USERNAME) data_schema=self._build_credentials_schema(user_input),
self.password = config_data.get(CONF_PASSWORD) errors=errors,
)
return self.async_update_reload_and_abort(
existing_entry, data_updates=user_input, reason="reauth_successful"
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration flow."""
existing_entry = self._get_reconfigure_entry()
if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self._build_connection_schema(existing_entry.data),
)
# Merge existing data with user input for validation
validate_data = {**existing_entry.data, **user_input}
errors = await self._async_validate_credentials(validate_data)
if errors:
return self.async_show_form(
step_id="reconfigure",
data_schema=self._build_connection_schema(user_input),
errors=errors,
)
# Prevent reconfiguring to a different physical device
# it gets the unique ID from the device info when it validates credentials
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
existing_entry,
data_updates=user_input,
reason="reconfigure_successful",
)
async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]:
"""Validate connection credentials and return errors dict."""
self.host = data[CONF_HOST]
self.port = data.get(CONF_PORT, DEFAULT_PORT)
self.passkey = data.get(CONF_PASSKEY)
self.username = data.get(CONF_USERNAME)
self.password = data.get(CONF_PASSWORD)
errors: dict[str, str] = {}
try: try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError: except BSBLANAuthError:
return self.async_show_form( errors["base"] = "invalid_auth"
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
except BSBLANError: except BSBLANError:
return self.async_show_form( errors["base"] = "cannot_connect"
step_id="reauth_confirm", return errors
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
# Update only the fields that were provided by the user @callback
return self.async_update_reload_and_abort( def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
existing_entry, data_updates=user_input, reason="reauth_successful" """Build schema for credentials-only forms (reauth)."""
return vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
)
@callback
def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
"""Build schema for full connection forms (user and reconfigure)."""
return vol.Schema(
{
vol.Required(
CONF_HOST,
default=defaults.get(CONF_HOST, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PORT,
default=defaults.get(CONF_PORT, DEFAULT_PORT),
): int,
vol.Optional(
CONF_PASSKEY,
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
) )
@callback @callback
@@ -274,32 +306,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self, errors: dict | None = None, user_input: dict[str, Any] | None = None self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show the setup form to the user.""" """Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=self._build_connection_schema(user_input or {}),
{
vol.Required(
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
}
),
errors=errors or {}, errors=errors or {},
) )

View File

@@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics(
"sensor": data.fast_coordinator.data.sensor.model_dump(), "sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump(), "dhw": data.fast_coordinator.data.dhw.model_dump(),
}, },
"static": data.static.model_dump(), "static": data.static.model_dump() if data.static is not None else None,
} }
# Add DHW config and schedule from slow coordinator if available # Add DHW config and schedule from slow coordinator if available

View File

@@ -58,7 +58,7 @@ rules:
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: todo icon-translations: todo
reconfiguration-flow: todo reconfiguration-flow: done
repair-issues: repair-issues:
status: exempt status: exempt
comment: | comment: |

View File

@@ -3,7 +3,9 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"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%]",
"unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured."
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -39,6 +41,24 @@
"description": "The BSB-LAN integration needs to re-authenticate with {name}", "description": "The BSB-LAN integration needs to re-authenticate with {name}",
"title": "[%key:common::config_flow::title::reauth%]" "title": "[%key:common::config_flow::title::reauth%]"
}, },
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "[%key:component::bsblan::config::step::user::data_description::host%]",
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"port": "[%key:component::bsblan::config::step::user::data_description::port%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "Update connection settings for your BSB-LAN device.",
"title": "Reconfigure BSB-LAN"
},
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",

View File

@@ -66,6 +66,7 @@ class ClementineDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY
) )
_attr_volume_step = 0.04
def __init__(self, client, name): def __init__(self, client, name):
"""Initialize the Clementine device.""" """Initialize the Clementine device."""
@@ -124,16 +125,6 @@ class ClementineDevice(MediaPlayerEntity):
return None, None return None, None
def volume_up(self) -> None:
"""Volume up the media player."""
newvolume = min(self._client.volume + 4, 100)
self._client.set_volume(newvolume)
def volume_down(self) -> None:
"""Volume down media player."""
newvolume = max(self._client.volume - 4, 0)
self._client.set_volume(newvolume)
def mute_volume(self, mute: bool) -> None: def mute_volume(self, mute: bool) -> None:
"""Send mute command.""" """Send mute command."""
self._client.set_volume(0) self._client.set_volume(0)

View File

@@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .condition import make_cover_is_closed_condition, make_cover_is_open_condition
from .const import ( from .const import (
ATTR_CURRENT_POSITION, ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION, ATTR_CURRENT_TILT_POSITION,
@@ -80,6 +81,8 @@ __all__ = [
"CoverEntityFeature", "CoverEntityFeature",
"CoverState", "CoverState",
"make_cover_closed_trigger", "make_cover_closed_trigger",
"make_cover_is_closed_condition",
"make_cover_is_open_condition",
"make_cover_opened_trigger", "make_cover_opened_trigger",
] ]

View File

@@ -9,9 +9,12 @@ from typing import Any
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.helpers.event import async_track_utc_time_change
from . import DOMAIN
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
@@ -23,10 +26,10 @@ async def async_setup_entry(
"""Set up the Demo config entry.""" """Set up the Demo config entry."""
async_add_entities( async_add_entities(
[ [
DemoValve("Front Garden", ValveState.OPEN), DemoValve("valve_1", "Front Garden", ValveState.OPEN),
DemoValve("Orchard", ValveState.CLOSED), DemoValve("valve_2", "Orchard", ValveState.CLOSED),
DemoValve("Back Garden", ValveState.CLOSED, position=70), DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70),
DemoValve("Trees", ValveState.CLOSED, position=30), DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30),
] ]
) )
@@ -34,17 +37,24 @@ async def async_setup_entry(
class DemoValve(ValveEntity): class DemoValve(ValveEntity):
"""Representation of a Demo valve.""" """Representation of a Demo valve."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False _attr_should_poll = False
def __init__( def __init__(
self, self,
unique_id: str,
name: str, name: str,
state: str, state: str,
moveable: bool = True, moveable: bool = True,
position: int | None = None, position: int | None = None,
) -> None: ) -> None:
"""Initialize the valve.""" """Initialize the valve."""
self._attr_name = name self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=name,
)
if moveable: if moveable:
self._attr_supported_features = ( self._attr_supported_features = (
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE

View File

@@ -0,0 +1,29 @@
"""Provides conditions for doors."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
COVER_DOMAIN: CoverDeviceClass.DOOR,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_DOOR),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_DOOR),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for doors."""
return CONDITIONS

View File

@@ -0,0 +1,28 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door
is_open:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: door
- domain: cover
device_class: door

View File

@@ -1,4 +1,12 @@
{ {
"conditions": {
"is_closed": {
"condition": "mdi:door-closed"
},
"is_open": {
"condition": "mdi:door-open"
}
},
"triggers": { "triggers": {
"closed": { "closed": {
"trigger": "mdi:door-closed" "trigger": "mdi:door-closed"

View File

@@ -1,9 +1,39 @@
{ {
"common": { "common": {
"condition_behavior_description": "How the state should match on the targeted doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.", "trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_name": "Behavior" "trigger_behavior_name": "Behavior"
}, },
"conditions": {
"is_closed": {
"description": "Tests if one or more doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
"name": "Door is closed"
},
"is_open": {
"description": "Tests if one or more doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
"name": "Door is open"
}
},
"selector": { "selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": { "trigger_behavior": {
"options": { "options": {
"any": "Any", "any": "Any",

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

@@ -23,6 +23,23 @@
"alarm_sound_mode": { "alarm_sound_mode": {
"default": "mdi:alarm" "default": "mdi:alarm"
} }
},
"sensor": {
"alarm_sound_mode": {
"default": "mdi:alarm"
},
"last_alarm_type_code": {
"default": "mdi:alarm"
},
"last_alarm_type_name": {
"default": "mdi:alarm"
},
"local_ip": {
"default": "mdi:ip"
},
"wan_ip": {
"default": "mdi:ip"
}
} }
}, },
"services": { "services": {

View File

@@ -27,6 +27,7 @@ class FullyButtonEntityDescription(ButtonEntityDescription):
"""Fully Kiosk Browser button description.""" """Fully Kiosk Browser button description."""
press_action: Callable[[FullyKiosk], Any] press_action: Callable[[FullyKiosk], Any]
refresh_after_press: bool = True
BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
@@ -68,6 +69,13 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
press_action=lambda fully: fully.clearCache(), press_action=lambda fully: fully.clearCache(),
), ),
FullyButtonEntityDescription(
key="triggerMotion",
translation_key="trigger_motion",
entity_category=EntityCategory.CONFIG,
press_action=lambda fully: fully.triggerMotion(),
refresh_after_press=False,
),
) )
@@ -102,4 +110,5 @@ class FullyButtonEntity(FullyKioskEntity, ButtonEntity):
async def async_press(self) -> None: async def async_press(self) -> None:
"""Set the value of the entity.""" """Set the value of the entity."""
await self.entity_description.press_action(self.coordinator.fully) await self.entity_description.press_action(self.coordinator.fully)
await self.coordinator.async_refresh() if self.entity_description.refresh_after_press:
await self.coordinator.async_refresh()

View File

@@ -12,7 +12,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfInformation,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
@@ -56,6 +61,14 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
FullySensorEntityDescription(
key="batteryTemperature",
translation_key="battery_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
FullySensorEntityDescription( FullySensorEntityDescription(
key="currentPage", key="currentPage",
translation_key="current_page", translation_key="current_page",

View File

@@ -88,6 +88,9 @@
}, },
"to_foreground": { "to_foreground": {
"name": "Bring to foreground" "name": "Bring to foreground"
},
"trigger_motion": {
"name": "Trigger motion activity"
} }
}, },
"image": { "image": {
@@ -118,6 +121,9 @@
} }
}, },
"sensor": { "sensor": {
"battery_temperature": {
"name": "Battery temperature"
},
"current_page": { "current_page": {
"name": "Current page" "name": "Current page"
}, },

View File

@@ -0,0 +1,31 @@
"""Provides conditions for garage doors."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR,
COVER_DOMAIN: CoverDeviceClass.GARAGE,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(
device_classes=DEVICE_CLASSES_GARAGE_DOOR
),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for garage doors."""
return CONDITIONS

View File

@@ -0,0 +1,28 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage
is_open:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage

View File

@@ -1,4 +1,12 @@
{ {
"conditions": {
"is_closed": {
"condition": "mdi:garage"
},
"is_open": {
"condition": "mdi:garage-open"
}
},
"triggers": { "triggers": {
"closed": { "closed": {
"trigger": "mdi:garage" "trigger": "mdi:garage"

View File

@@ -1,9 +1,39 @@
{ {
"common": { "common": {
"condition_behavior_description": "How the state should match on the targeted garage doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_name": "Behavior" "trigger_behavior_name": "Behavior"
}, },
"conditions": {
"is_closed": {
"description": "Tests if one or more garage doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
"name": "Garage door is closed"
},
"is_open": {
"description": "Tests if one or more garage doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
"name": "Garage door is open"
}
},
"selector": { "selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": { "trigger_behavior": {
"options": { "options": {
"any": "Any", "any": "Any",

View File

@@ -0,0 +1,24 @@
"""Provides conditions for gates."""
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_is_closed_condition,
make_cover_is_open_condition,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition
DEVICE_CLASSES_GATE: dict[str, str] = {
COVER_DOMAIN: CoverDeviceClass.GATE,
}
CONDITIONS: dict[str, type[Condition]] = {
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_GATE),
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GATE),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for gates."""
return CONDITIONS

View File

@@ -0,0 +1,24 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_closed:
fields: *condition_common_fields
target:
entity:
- domain: cover
device_class: gate
is_open:
fields: *condition_common_fields
target:
entity:
- domain: cover
device_class: gate

View File

@@ -1,4 +1,12 @@
{ {
"conditions": {
"is_closed": {
"condition": "mdi:gate"
},
"is_open": {
"condition": "mdi:gate-open"
}
},
"triggers": { "triggers": {
"closed": { "closed": {
"trigger": "mdi:gate" "trigger": "mdi:gate"

View File

@@ -1,9 +1,39 @@
{ {
"common": { "common": {
"condition_behavior_description": "How the state should match on the targeted gates.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.", "trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_name": "Behavior" "trigger_behavior_name": "Behavior"
}, },
"conditions": {
"is_closed": {
"description": "Tests if one or more gates are closed.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
"name": "Gate is closed"
},
"is_open": {
"description": "Tests if one or more gates are open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
"name": "Gate is open"
}
},
"selector": { "selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": { "trigger_behavior": {
"options": { "options": {
"any": "Any", "any": "Any",

View File

@@ -12,6 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session, OAuth2Session,
async_get_config_entry_implementation, async_get_config_entry_implementation,
) )
@@ -30,11 +31,17 @@ _PLATFORMS = (Platform.SENSOR,)
async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
"""Set up Google Drive from a config entry.""" """Set up Google Drive from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
auth = AsyncConfigEntryAuth( auth = AsyncConfigEntryAuth(
async_get_clientsession(hass), async_get_clientsession(hass),
OAuth2Session( OAuth2Session(hass, entry, implementation),
hass, entry, await async_get_config_entry_implementation(hass, entry)
),
) )
# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
@@ -46,7 +53,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
try: try:
folder_id, _ = await client.async_create_ha_root_folder_if_not_exists() folder_id, _ = await client.async_create_ha_root_folder_if_not_exists()
except GoogleDriveApiError as err: except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "Home Assistant"},
) from err
def async_notify_backup_listeners() -> None: def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):

View File

@@ -22,6 +22,8 @@ from homeassistant.exceptions import (
) )
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 _UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
_UPLOAD_MAX_RETRIES = 20 _UPLOAD_MAX_RETRIES = 20
@@ -61,14 +63,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
): ):
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required" translation_domain=DOMAIN,
translation_key="authentication_not_valid",
) from ex ) from ex
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
if hasattr(ex, "status") and ex.status == 400: if hasattr(ex, "status") and ex.status == 400:
self._oauth_session.config_entry.async_start_reauth( self._oauth_session.config_entry.async_start_reauth(
self._oauth_session.hass self._oauth_session.hass
) )
raise HomeAssistantError(ex) from ex raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
return str(self._oauth_session.token[CONF_ACCESS_TOKEN]) return str(self._oauth_session.token[CONF_ACCESS_TOKEN])

View File

@@ -8,7 +8,11 @@ from typing import Any, cast
from google_drive_api.exceptions import GoogleDriveApiError from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow, instance_id from homeassistant.helpers import config_entry_oauth2_flow, instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -44,6 +48,12 @@ class OAuth2FlowHandler(
"prompt": "consent", "prompt": "consent",
} }
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -81,13 +91,16 @@ class OAuth2FlowHandler(
await self.async_set_unique_id(email_address) await self.async_set_unique_id(email_address)
if self.source == SOURCE_REAUTH: if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
reauth_entry = self._get_reauth_entry() if self.source == SOURCE_REAUTH:
entry = self._get_reauth_entry()
else:
entry = self._get_reconfigure_entry()
self._abort_if_unique_id_mismatch( self._abort_if_unique_id_mismatch(
reason="wrong_account", reason="wrong_account",
description_placeholders={"email": cast(str, reauth_entry.unique_id)}, description_placeholders={"email": cast(str, entry.unique_id)},
) )
return self.async_update_reload_and_abort(reauth_entry, data=data) return self.async_update_reload_and_abort(entry, data=data)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()

View File

@@ -17,9 +17,7 @@ rules:
docs-removal-instructions: done docs-removal-instructions: done
entity-event-setup: done entity-event-setup: done
entity-unique-id: done entity-unique-id: done
has-entity-name: has-entity-name: done
status: exempt
comment: No entities.
runtime-data: done runtime-data: done
test-before-configure: done test-before-configure: done
test-before-setup: done test-before-setup: done
@@ -66,12 +64,8 @@ rules:
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: icon-translations: done
status: exempt reconfiguration-flow: done
comment: No entities.
reconfiguration-flow:
status: exempt
comment: No configuration options.
repair-issues: repair-issues:
status: exempt status: exempt
comment: No repairs. comment: No repairs.

View File

@@ -18,6 +18,7 @@
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"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%]",
"unknown": "[%key:common::config_flow::error::unknown%]", "unknown": "[%key:common::config_flow::error::unknown%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with {email}." "wrong_account": "Wrong account: Please authenticate with {email}."
@@ -62,5 +63,22 @@
"name": "Used storage in Drive Trash" "name": "Used storage in Drive Trash"
} }
} }
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed"
},
"authentication_not_valid": {
"message": "OAuth session is not valid, reauthentication required"
},
"failed_to_get_folder": {
"message": "Failed to get {folder} folder"
},
"invalid_response_google_drive_error": {
"message": "Invalid response from Google Drive: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
} }
} }

View File

@@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, UpdateFailed,
) )
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
T = TypeVar( T = TypeVar(
@@ -97,7 +99,13 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
self.subentry.title, self.subentry.title,
err, err,
) )
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"error": str(err),
},
) from err
class GoogleWeatherCurrentConditionsCoordinator( class GoogleWeatherCurrentConditionsCoordinator(

View File

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

View File

@@ -98,5 +98,10 @@
"name": "Wind gust speed" "name": "Wind gust speed"
} }
} }
},
"exceptions": {
"update_error": {
"message": "Error fetching weather data: {error}"
}
} }
} }

View File

@@ -251,7 +251,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def get_data( def get_data(
self, entity_description: GrowattSensorEntityDescription self, entity_description: GrowattSensorEntityDescription
) -> str | int | float | None: ) -> str | int | float | datetime.datetime | datetime.date | None:
"""Get the data.""" """Get the data."""
variable = entity_description.api_key variable = entity_description.api_key
api_value = self.data.get(variable) api_value = self.data.get(variable)
@@ -372,7 +372,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Updating time segments requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
try: try:
@@ -388,7 +389,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
enabled, enabled,
) )
except growattServer.GrowattV1ApiError as err: except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(f"API error updating time segment: {err}") from err raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
# Update coordinator's cached data without making an API call (avoids rate limit) # Update coordinator's cached data without making an API call (avoids rate limit)
if self.data: if self.data:
@@ -411,7 +416,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Reading time segments requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
# Ensure we have current data # Ensure we have current data
@@ -496,7 +502,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
""" """
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Updating AC charge times requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
try: try:
@@ -510,7 +517,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
except growattServer.GrowattV1ApiError as err: except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError( raise HomeAssistantError(
f"API error updating AC charge times: {err}" translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err ) from err
if self.data: if self.data:
@@ -544,7 +553,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
""" """
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Updating AC discharge times requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
try: try:
@@ -557,7 +567,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
except growattServer.GrowattV1ApiError as err: except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError( raise HomeAssistantError(
f"API error updating AC discharge times: {err}" translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err ) from err
if self.data: if self.data:
@@ -579,7 +591,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Read AC charge time settings from SPH device cache.""" """Read AC charge time settings from SPH device cache."""
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Reading AC charge times requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
if not self.data: if not self.data:
@@ -591,7 +604,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Read AC discharge time settings from SPH device cache.""" """Read AC discharge time settings from SPH device cache."""
if self.api_version != "v1": if self.api_version != "v1":
raise ServiceValidationError( raise ServiceValidationError(
"Reading AC discharge times requires token authentication" translation_domain=DOMAIN,
translation_key="token_auth_required",
) )
if not self.data: if not self.data:

View File

@@ -0,0 +1,65 @@
"""Diagnostics support for Growatt Server."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import CONF_PLANT_ID
from .coordinator import GrowattConfigEntry
TO_REDACT = {
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
CONF_UNIQUE_ID,
CONF_PLANT_ID,
"user_id",
"deviceSn",
"device_sn",
}
# Allowlist of safe telemetry fields from the total coordinator.
# Monetary fields (plantMoneyText, totalMoneyText, currency) are intentionally
# excluded to avoid leaking financial data under unpredictable key names.
_TOTAL_SAFE_KEYS = frozenset(
{
# Classic API keys
"todayEnergy",
"totalEnergy",
"invTodayPpv",
"nominalPower",
# V1 API keys (aliases used after normalisation in coordinator)
"today_energy",
"total_energy",
"current_power",
}
)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data = config_entry.runtime_data
total_data = runtime_data.total_coordinator.data or {}
return async_redact_data(
{
"config_entry": config_entry.as_dict(),
"total_coordinator": {
k: v for k, v in total_data.items() if k in _TOTAL_SAFE_KEYS
},
"devices": [
{
"device_sn": device_sn,
"device_type": coordinator.device_type,
"data": coordinator.data,
}
for device_sn, coordinator in runtime_data.devices.items()
],
},
TO_REDACT,
)

View File

@@ -1,4 +1,17 @@
{ {
"entity": {
"sensor": {
"storage_load_consumption_solar_storage": {
"default": "mdi:lightning-bolt"
},
"total_money_today": {
"default": "mdi:cash"
},
"total_money_total": {
"default": "mdi:cash"
}
}
},
"services": { "services": {
"read_ac_charge_times": { "read_ac_charge_times": {
"service": "mdi:battery-clock-outline" "service": "mdi:battery-clock-outline"

View File

@@ -17,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -27,9 +26,10 @@ PARALLEL_UPDATES = (
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin): class GrowattNumberEntityDescription(NumberEntityDescription):
"""Describes Growatt number entity.""" """Describes Growatt number entity."""
api_key: str
write_key: str | None = None # Parameter ID for writing (if different from api_key) write_key: str | None = None # Parameter ID for writing (if different from api_key)
@@ -130,6 +130,7 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
identifiers={(DOMAIN, coordinator.device_id)}, identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt", manufacturer="Growatt",
name=coordinator.device_id, name=coordinator.device_id,
serial_number=coordinator.device_id,
) )
@property @property
@@ -157,7 +158,11 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
int_value, int_value,
) )
except GrowattV1ApiError as e: except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting parameter: {e}") from e raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(e)},
) from e
# If no exception was raised, the write was successful # If no exception was raised, the write was successful
_LOGGER.debug( _LOGGER.debug(

View File

@@ -32,10 +32,8 @@ rules:
test-coverage: done test-coverage: done
# Gold # Gold
devices: devices: done
status: todo diagnostics: done
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
diagnostics: todo
discovery-update-info: todo discovery-update-info: todo
discovery: todo discovery: todo
docs-data-update: todo docs-data-update: todo
@@ -46,16 +44,12 @@ rules:
docs-troubleshooting: todo docs-troubleshooting: todo
docs-use-cases: todo docs-use-cases: todo
dynamic-devices: todo dynamic-devices: todo
entity-category: entity-category: done
status: todo entity-device-class: done
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True. entity-disabled-by-default: done
entity-device-class:
status: todo
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
entity-disabled-by-default: todo
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: todo icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:
status: exempt status: exempt

View File

@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime
import logging import logging
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from ..const import DOMAIN from ..const import DOMAIN
@@ -99,24 +101,18 @@ class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
self.entity_description = description self.entity_description = description
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_icon = "mdi:solar-power"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_id)}, identifiers={(DOMAIN, serial_id)},
manufacturer="Growatt", manufacturer="Growatt",
name=name, name=name,
serial_number=serial_id,
) )
@property @property
def native_value(self) -> str | int | float | None: def native_value(self) -> StateType | date | datetime:
"""Return the state of the sensor.""" """Return the state of the sensor."""
result = self.coordinator.get_data(self.entity_description) return self.coordinator.get_data(self.entity_description)
if (
isinstance(result, (int, float))
and self.entity_description.precision is not None
):
result = round(result, self.entity_description.precision)
return result
@property @property
def native_unit_of_measurement(self) -> str | None: def native_unit_of_measurement(self) -> str | None:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
EntityCategory,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
@@ -22,7 +23,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_energy_total", key="inverter_energy_total",
@@ -30,7 +31,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="powerTotal", api_key="powerTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
precision=1, suggested_display_precision=1,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -40,7 +41,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_amperage_input_1", key="inverter_amperage_input_1",
@@ -49,7 +50,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_wattage_input_1", key="inverter_wattage_input_1",
@@ -58,7 +59,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_voltage_input_2", key="inverter_voltage_input_2",
@@ -67,7 +68,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_amperage_input_2", key="inverter_amperage_input_2",
@@ -76,7 +77,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_wattage_input_2", key="inverter_wattage_input_2",
@@ -85,7 +86,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_voltage_input_3", key="inverter_voltage_input_3",
@@ -94,7 +95,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_amperage_input_3", key="inverter_amperage_input_3",
@@ -103,7 +104,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_wattage_input_3", key="inverter_wattage_input_3",
@@ -112,7 +113,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_internal_wattage", key="inverter_internal_wattage",
@@ -121,7 +122,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_reactive_voltage", key="inverter_reactive_voltage",
@@ -130,7 +131,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_inverter_reactive_amperage", key="inverter_inverter_reactive_amperage",
@@ -139,7 +142,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_frequency", key="inverter_frequency",
@@ -148,7 +153,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ, native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY, device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_current_wattage", key="inverter_current_wattage",
@@ -157,7 +164,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_current_reactive_wattage", key="inverter_current_reactive_wattage",
@@ -166,7 +173,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_ipm_temperature", key="inverter_ipm_temperature",
@@ -175,7 +184,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="inverter_temperature", key="inverter_temperature",
@@ -184,6 +195,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
) )

View File

@@ -7,18 +7,11 @@ from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True) @dataclass(frozen=True, kw_only=True)
class GrowattRequiredKeysMixin: class GrowattSensorEntityDescription(SensorEntityDescription):
"""Mixin for required keys."""
api_key: str
@dataclass(frozen=True)
class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin):
"""Describes Growatt sensor entity.""" """Describes Growatt sensor entity."""
precision: int | None = None api_key: str
currency: bool = False currency: bool = False
previous_value_drop_threshold: float | None = None previous_value_drop_threshold: float | None = None
never_resets: bool = False never_resets: bool = False

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
UnitOfFrequency, UnitOfFrequency,
@@ -90,6 +91,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ, native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY, device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="sph_temperature_1", key="sph_temperature_1",
@@ -98,6 +101,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="sph_temperature_2", key="sph_temperature_2",
@@ -106,6 +111,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="sph_temperature_3", key="sph_temperature_3",
@@ -114,6 +121,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="sph_temperature_4", key="sph_temperature_4",
@@ -122,6 +131,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="sph_temperature_5", key="sph_temperature_5",
@@ -130,6 +141,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
# Values from 'sph_energy' API call # Values from 'sph_energy' API call
GrowattSensorEntityDescription( GrowattSensorEntityDescription(

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
@@ -189,7 +190,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_pv_charging_voltage", key="storage_pv_charging_voltage",
@@ -198,7 +199,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_pv_charging_voltage_2", key="storage_pv_charging_voltage_2",
@@ -207,7 +208,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_ac_input_frequency_out", key="storage_ac_input_frequency_out",
@@ -216,7 +217,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ, native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY, device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_output_voltage", key="storage_output_voltage",
@@ -225,7 +228,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_ac_output_frequency", key="storage_ac_output_frequency",
@@ -234,7 +237,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ, native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY, device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_current_PV", key="storage_current_PV",
@@ -243,7 +248,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_current_1", key="storage_current_1",
@@ -252,7 +257,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_current_2", key="storage_current_2",
@@ -261,7 +266,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_grid_amperage_input", key="storage_grid_amperage_input",
@@ -270,7 +275,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_grid_out_current", key="storage_grid_out_current",
@@ -279,7 +284,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_battery_voltage", key="storage_battery_voltage",
@@ -288,7 +293,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="storage_load_percentage", key="storage_load_percentage",
@@ -297,6 +302,6 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=2, suggested_display_precision=2,
), ),
) )

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
@@ -26,7 +27,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_energy_total", key="tlx_energy_total",
@@ -35,7 +36,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
never_resets=True, never_resets=True,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -45,7 +46,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
never_resets=True, never_resets=True,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -55,7 +56,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_voltage_input_1", key="tlx_voltage_input_1",
@@ -63,7 +64,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv1", api_key="vpv1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_amperage_input_1", key="tlx_amperage_input_1",
@@ -71,7 +72,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv1", api_key="ipv1",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_wattage_input_1", key="tlx_wattage_input_1",
@@ -80,7 +81,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_energy_total_input_2", key="tlx_energy_total_input_2",
@@ -89,7 +90,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
never_resets=True, never_resets=True,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -99,7 +100,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_voltage_input_2", key="tlx_voltage_input_2",
@@ -107,7 +108,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv2", api_key="vpv2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_amperage_input_2", key="tlx_amperage_input_2",
@@ -115,7 +116,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv2", api_key="ipv2",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_wattage_input_2", key="tlx_wattage_input_2",
@@ -124,7 +125,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_energy_total_input_3", key="tlx_energy_total_input_3",
@@ -133,7 +134,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
never_resets=True, never_resets=True,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -143,7 +144,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_voltage_input_3", key="tlx_voltage_input_3",
@@ -151,7 +152,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv3", api_key="vpv3",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_amperage_input_3", key="tlx_amperage_input_3",
@@ -159,7 +160,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv3", api_key="ipv3",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_wattage_input_3", key="tlx_wattage_input_3",
@@ -168,7 +169,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_energy_total_input_4", key="tlx_energy_total_input_4",
@@ -177,7 +178,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
never_resets=True, never_resets=True,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
@@ -187,7 +188,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_voltage_input_4", key="tlx_voltage_input_4",
@@ -195,7 +196,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vpv4", api_key="vpv4",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_amperage_input_4", key="tlx_amperage_input_4",
@@ -203,7 +204,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="ipv4", api_key="ipv4",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT, device_class=SensorDeviceClass.CURRENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_wattage_input_4", key="tlx_wattage_input_4",
@@ -212,7 +213,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_solar_generation_today", key="tlx_solar_generation_today",
@@ -221,7 +222,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_solar_generation_total", key="tlx_solar_generation_total",
@@ -239,7 +240,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_reactive_voltage", key="tlx_reactive_voltage",
@@ -247,7 +248,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="vacrs", api_key="vacrs",
native_unit_of_measurement=UnitOfElectricPotential.VOLT, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_frequency", key="tlx_frequency",
@@ -255,7 +258,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="fac", api_key="fac",
native_unit_of_measurement=UnitOfFrequency.HERTZ, native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY, device_class=SensorDeviceClass.FREQUENCY,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_current_wattage", key="tlx_current_wattage",
@@ -264,7 +269,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_temperature_1", key="tlx_temperature_1",
@@ -272,7 +277,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp1", api_key="temp1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_temperature_2", key="tlx_temperature_2",
@@ -280,7 +287,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp2", api_key="temp2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_temperature_3", key="tlx_temperature_3",
@@ -288,7 +297,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp3", api_key="temp3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_temperature_4", key="tlx_temperature_4",
@@ -296,7 +307,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp4", api_key="temp4",
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_temperature_5", key="tlx_temperature_5",
@@ -304,7 +317,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="temp5", api_key="temp5",
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
precision=1, suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_all_batteries_discharge_today", key="tlx_all_batteries_discharge_today",
@@ -456,7 +471,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_pac_to_user_total", key="tlx_pac_to_user_total",
@@ -465,7 +480,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_pac_to_grid_total", key="tlx_pac_to_grid_total",
@@ -474,7 +489,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_system_production_today", key="tlx_system_production_today",
@@ -483,7 +498,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_system_production_total", key="tlx_system_production_total",
@@ -493,7 +508,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True, never_resets=True,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_self_consumption_today", key="tlx_self_consumption_today",
@@ -502,7 +517,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_self_consumption_total", key="tlx_self_consumption_total",
@@ -512,7 +527,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True, never_resets=True,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_import_from_grid_today", key="tlx_import_from_grid_today",
@@ -521,7 +536,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_import_from_grid_total", key="tlx_import_from_grid_total",
@@ -531,7 +546,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True, never_resets=True,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_today", key="tlx_batteries_charged_from_grid_today",
@@ -540,7 +555,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_batteries_charged_from_grid_total", key="tlx_batteries_charged_from_grid_total",
@@ -550,7 +565,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
never_resets=True, never_resets=True,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_p_system", key="tlx_p_system",
@@ -559,7 +574,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
GrowattSensorEntityDescription( GrowattSensorEntityDescription(
key="tlx_p_self", key="tlx_p_self",
@@ -568,6 +583,6 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
precision=1, suggested_display_precision=1,
), ),
) )

View File

@@ -46,15 +46,20 @@ def _get_coordinator(
if not coordinators: if not coordinators:
raise ServiceValidationError( raise ServiceValidationError(
f"No {device_type.upper()} devices with token authentication are configured. " translation_domain=DOMAIN,
f"Services require {device_type.upper()} devices with V1 API access." translation_key="no_devices_configured",
translation_placeholders={"device_type": device_type.upper()},
) )
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id) device_entry = device_registry.async_get(device_id)
if not device_entry: if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found") raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device_id": device_id},
)
serial_number = None serial_number = None
for identifier in device_entry.identifiers: for identifier in device_entry.identifiers:
@@ -63,11 +68,20 @@ def _get_coordinator(
break break
if not serial_number: if not serial_number:
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device") raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_growatt",
translation_placeholders={"device_id": device_id},
)
if serial_number not in coordinators: if serial_number not in coordinators:
raise ServiceValidationError( raise ServiceValidationError(
f"{device_type.upper()} device '{serial_number}' not found or not configured for services" translation_domain=DOMAIN,
translation_key="device_not_configured",
translation_placeholders={
"device_type": device_type.upper(),
"serial_number": serial_number,
},
) )
return coordinators[serial_number] return coordinators[serial_number]
@@ -78,13 +92,17 @@ def _parse_time_str(time_str: str, field_name: str) -> time:
parts = time_str.split(":") parts = time_str.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ServiceValidationError( raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format" translation_domain=DOMAIN,
translation_key="invalid_time_format",
translation_placeholders={"field_name": field_name},
) )
try: try:
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time() return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
except (ValueError, IndexError) as err: except (ValueError, IndexError) as err:
raise ServiceValidationError( raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format" translation_domain=DOMAIN,
translation_key="invalid_time_format",
translation_placeholders={"field_name": field_name},
) from err ) from err
@@ -103,7 +121,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 1 <= segment_id <= 9: if not 1 <= segment_id <= 9:
raise ServiceValidationError( raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}" translation_domain=DOMAIN,
translation_key="invalid_segment_id",
translation_placeholders={"segment_id": str(segment_id)},
) )
valid_modes = { valid_modes = {
@@ -113,7 +133,12 @@ def async_setup_services(hass: HomeAssistant) -> None:
} }
if batt_mode_str not in valid_modes: if batt_mode_str not in valid_modes:
raise ServiceValidationError( raise ServiceValidationError(
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'" translation_domain=DOMAIN,
translation_key="invalid_batt_mode",
translation_placeholders={
"batt_mode": batt_mode_str,
"allowed_modes": ", ".join(valid_modes),
},
) )
batt_mode: int = valid_modes[batt_mode_str] batt_mode: int = valid_modes[batt_mode_str]
@@ -151,11 +176,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 0 <= charge_power <= 100: if not 0 <= charge_power <= 100:
raise ServiceValidationError( raise ServiceValidationError(
f"charge_power must be between 0 and 100, got {charge_power}" translation_domain=DOMAIN,
translation_key="invalid_charge_power",
translation_placeholders={"value": str(charge_power)},
) )
if not 0 <= charge_stop_soc <= 100: if not 0 <= charge_stop_soc <= 100:
raise ServiceValidationError( raise ServiceValidationError(
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}" translation_domain=DOMAIN,
translation_key="invalid_charge_stop_soc",
translation_placeholders={"value": str(charge_stop_soc)},
) )
periods = [] periods = []
@@ -193,11 +222,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 0 <= discharge_power <= 100: if not 0 <= discharge_power <= 100:
raise ServiceValidationError( raise ServiceValidationError(
f"discharge_power must be between 0 and 100, got {discharge_power}" translation_domain=DOMAIN,
translation_key="invalid_discharge_power",
translation_placeholders={"value": str(discharge_power)},
) )
if not 0 <= discharge_stop_soc <= 100: if not 0 <= discharge_stop_soc <= 100:
raise ServiceValidationError( raise ServiceValidationError(
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}" translation_domain=DOMAIN,
translation_key="invalid_discharge_stop_soc",
translation_placeholders={"value": str(discharge_stop_soc)},
) )
periods = [] periods = []

View File

@@ -574,6 +574,47 @@
} }
} }
}, },
"exceptions": {
"api_error": {
"message": "Growatt API error: {error}"
},
"device_not_configured": {
"message": "{device_type} device {serial_number} is not configured for services."
},
"device_not_found": {
"message": "Device {device_id} not found in the device registry."
},
"device_not_growatt": {
"message": "Device {device_id} is not a Growatt device."
},
"invalid_batt_mode": {
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
},
"invalid_charge_power": {
"message": "charge_power must be between 0 and 100, got {value}."
},
"invalid_charge_stop_soc": {
"message": "charge_stop_soc must be between 0 and 100, got {value}."
},
"invalid_discharge_power": {
"message": "discharge_power must be between 0 and 100, got {value}."
},
"invalid_discharge_stop_soc": {
"message": "discharge_stop_soc must be between 0 and 100, got {value}."
},
"invalid_segment_id": {
"message": "segment_id must be between 1 and 9, got {segment_id}."
},
"invalid_time_format": {
"message": "{field_name} must be in HH:MM or HH:MM:SS format."
},
"no_devices_configured": {
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
},
"token_auth_required": {
"message": "This action requires token authentication (V1 API)."
}
},
"selector": { "selector": {
"batt_mode": { "batt_mode": {
"options": { "options": {

View File

@@ -18,7 +18,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -28,9 +27,10 @@ PARALLEL_UPDATES = (
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin): class GrowattSwitchEntityDescription(SwitchEntityDescription):
"""Describes Growatt switch entity.""" """Describes Growatt switch entity."""
api_key: str
write_key: str | None = None # Parameter ID for writing (if different from api_key) write_key: str | None = None # Parameter ID for writing (if different from api_key)
@@ -87,6 +87,7 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
identifiers={(DOMAIN, coordinator.device_id)}, identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt", manufacturer="Growatt",
name=coordinator.device_id, name=coordinator.device_id,
serial_number=coordinator.device_id,
) )
@property @property
@@ -124,7 +125,11 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
api_value, api_value,
) )
except GrowattV1ApiError as e: except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting switch state: {e}") from e raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(e)},
) from e
# If no exception was raised, the write was successful # If no exception was raised, the write was successful
_LOGGER.debug( _LOGGER.debug(

View File

@@ -119,7 +119,6 @@ from .coordinator import (
get_core_stats, get_core_stats,
get_host_info, get_host_info,
get_info, get_info,
get_issues_info,
get_network_info, get_network_info,
get_os_info, get_os_info,
get_store, get_store,
@@ -158,7 +157,6 @@ __all__ = [
"get_core_stats", "get_core_stats",
"get_host_info", "get_host_info",
"get_info", "get_info",
"get_issues_info",
"get_network_info", "get_network_info",
"get_os_info", "get_os_info",
"get_store", "get_store",

View File

@@ -132,6 +132,7 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned" ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space" ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon" ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon"
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed" ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
@@ -172,6 +173,7 @@ EXTRA_PLACEHOLDERS = {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
}, },
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS, ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS,
} }

View File

@@ -47,6 +47,7 @@ from .const import (
EVENT_SUPPORTED_CHANGED, EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS, EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
@@ -90,6 +91,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime", "issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE, ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
"issue_system_ntp_sync_failed",
} }
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -253,9 +256,10 @@ class SupervisorIssues:
def add_issue(self, issue: Issue) -> None: def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary.""" """Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS: if issue.key in ISSUE_KEYS_FOR_REPAIRS:
placeholders: dict[str, str] = {}
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
placeholders |= EXTRA_PLACEHOLDERS[issue.key] placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy()
else:
placeholders = {}
if issue.reference: if issue.reference:
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio", "documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["aiohasupervisor==0.4.1"], "requirements": ["aiohasupervisor==0.4.2"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -15,12 +15,13 @@ from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from . import get_addons_list, get_issues_info from . import get_addons_list
from .const import ( from .const import (
ATTR_SLUG, ATTR_SLUG,
EXTRA_PLACEHOLDERS, EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED, ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -30,6 +31,7 @@ from .const import (
PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE, PLACEHOLDER_KEY_REFERENCE,
) )
from .coordinator import get_issues_info
from .handler import get_supervisor_client from .handler import get_supervisor_client
from .issues import Issue, Suggestion from .issues import Issue, Suggestion
@@ -64,11 +66,16 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property @property
def description_placeholders(self) -> dict[str, str] | None: def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps.""" """Get description placeholders for steps."""
placeholders = {} if not self.issue:
if self.issue: return None
placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {})
if self.issue.reference: if self.issue.key in EXTRA_PLACEHOLDERS:
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[self.issue.key].copy()
else:
placeholders = {}
if self.issue.reference:
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
return placeholders or None return placeholders or None
@@ -232,6 +239,7 @@ async def async_create_fix_flow(
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
}: }:
return AddonIssueRepairFlow(hass, issue_id) return AddonIssueRepairFlow(hass, issue_id)

View File

@@ -85,6 +85,19 @@
}, },
"title": "Installed app is deprecated" "title": "Installed app is deprecated"
}, },
"issue_addon_deprecated_arch_addon": {
"fix_flow": {
"abort": {
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
},
"step": {
"addon_execute_remove": {
"description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
}
},
"title": "Installed app is built for unsupported architectures and/or machines"
},
"issue_addon_detached_addon_missing": { "issue_addon_detached_addon_missing": {
"description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.", "description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.",
"title": "Missing repository for an installed app" "title": "Missing repository for an installed app"
@@ -164,6 +177,19 @@
}, },
"title": "Multiple data disks detected" "title": "Multiple data disks detected"
}, },
"issue_system_ntp_sync_failed": {
"fix_flow": {
"abort": {
"apply_suggestion_fail": "Could not re-enable NTP. Check the Supervisor logs for more details."
},
"step": {
"system_enable_ntp": {
"description": "The device could not contact its configured time servers (NTP). Using a secondary online time check, we detected that the system clock was more than 1 hour incorrect. The time has been corrected and the NTP service was temporarily disabled so the correction could be applied. To keep the system time accurate, we recommend fixing the issue preventing access to the NTP servers.\n\nCheck the **Host logs** to investigate why NTP servers could not be reached. Once resolved, select **Submit** to re-enable the NTP service."
}
}
},
"title": "Time synchronization issue detected"
},
"issue_system_reboot_required": { "issue_system_reboot_required": {
"fix_flow": { "fix_flow": {
"abort": { "abort": {

View File

@@ -117,13 +117,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
# Map raw event type names to friendly names using SENSOR_MAP # Map raw event type names to friendly names using SENSOR_MAP
mapped_events: dict[str, list[int]] = {} mapped_events: dict[str, list[int]] = {}
for event_type, channels in nvr_events.items(): for event_type, channels in nvr_events.items():
friendly_name = SENSOR_MAP.get(event_type.lower(), event_type) event_key = event_type.lower()
# Skip videoloss - used as watchdog by pyhik, not a real sensor
if event_key == "videoloss":
continue
friendly_name = SENSOR_MAP.get(event_key)
if friendly_name is None:
_LOGGER.debug("Skipping unmapped event type: %s", event_type)
continue
if friendly_name in mapped_events: if friendly_name in mapped_events:
mapped_events[friendly_name].extend(channels) mapped_events[friendly_name].extend(channels)
else: else:
mapped_events[friendly_name] = list(channels) mapped_events[friendly_name] = list(channels)
_LOGGER.debug("Mapped NVR events: %s", mapped_events) _LOGGER.debug("Mapped NVR events: %s", mapped_events)
camera.inject_events(mapped_events) if mapped_events:
camera.inject_events(mapped_events)
else: else:
_LOGGER.debug( _LOGGER.debug(
"No event triggers returned from %s. " "No event triggers returned from %s. "

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiohomeconnect"], "loggers": ["aiohomeconnect"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.32.0"], "requirements": ["aiohomeconnect==0.33.0"],
"zeroconf": ["_homeconnect._tcp.local."] "zeroconf": ["_homeconnect._tcp.local."]
} }

View File

@@ -119,6 +119,10 @@ set_program_and_options:
- cooking_common_program_hood_automatic - cooking_common_program_hood_automatic
- cooking_common_program_hood_venting - cooking_common_program_hood_venting
- cooking_common_program_hood_delayed_shut_off - cooking_common_program_hood_delayed_shut_off
- cooking_oven_program_heating_mode_3_d_heating
- cooking_oven_program_heating_mode_air_fry
- cooking_oven_program_heating_mode_grill_large_area
- cooking_oven_program_heating_mode_grill_small_area
- cooking_oven_program_heating_mode_pre_heating - cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air - cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco - cooking_oven_program_heating_mode_hot_air_eco

View File

@@ -260,12 +260,16 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]", "cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
@@ -616,12 +620,16 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]", "cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
@@ -1621,12 +1629,16 @@
"cooking_common_program_hood_automatic": "Automatic", "cooking_common_program_hood_automatic": "Automatic",
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off", "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
"cooking_common_program_hood_venting": "Venting", "cooking_common_program_hood_venting": "Venting",
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
"cooking_oven_program_heating_mode_air_fry": "Air fry",
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
"cooking_oven_program_heating_mode_bread_baking": "Bread baking", "cooking_oven_program_heating_mode_bread_baking": "Bread baking",
"cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_defrost": "Defrost",
"cooking_oven_program_heating_mode_desiccation": "Desiccation", "cooking_oven_program_heating_mode_desiccation": "Desiccation",
"cooking_oven_program_heating_mode_dough_proving": "Dough proving", "cooking_oven_program_heating_mode_dough_proving": "Dough proving",
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products", "cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
"cooking_oven_program_heating_mode_grill_large_area": "Grill (large area)",
"cooking_oven_program_heating_mode_grill_small_area": "Grill (small area)",
"cooking_oven_program_heating_mode_hot_air": "Hot air", "cooking_oven_program_heating_mode_hot_air": "Hot air",
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
"cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["homematicip"], "loggers": ["homematicip"],
"requirements": ["homematicip==2.6.0"] "requirements": ["homematicip==2.7.0"]
} }

View File

@@ -72,13 +72,6 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
return HVACMode.HEAT return HVACMode.HEAT
return HVACMode.OFF return HVACMode.OFF
@property
def icon(self) -> str:
"""Return nice icon for heater."""
if self.hvac_mode == HVACMode.HEAT:
return "mdi:radiator"
return "mdi:radiator-off"
@property @property
def current_temperature(self) -> int | None: def current_temperature(self) -> int | None:
"""Return the current temperature.""" """Return the current temperature."""

View File

@@ -45,8 +45,6 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
) )
await huum.status() await huum.status()
except Forbidden, NotAuthenticated: except Forbidden, NotAuthenticated:
# Most likely Forbidden as that is what is returned from `.status()` with bad creds
_LOGGER.error("Could not log in to Huum with given credentials")
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except Exception: except Exception:
_LOGGER.exception("Unknown error") _LOGGER.exception("Unknown error")

View File

@@ -54,7 +54,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
try: try:
return await self.huum.status() return await self.huum.status()
except (Forbidden, NotAuthenticated) as err: except (Forbidden, NotAuthenticated) as err:
_LOGGER.error("Could not log in to Huum with given credentials")
raise UpdateFailed( raise UpdateFailed(
"Could not log in to Huum with given credentials" "Could not log in to Huum with given credentials"
) from err ) from err

View File

@@ -7,11 +7,7 @@ rules:
brands: done brands: done
common-modules: done common-modules: done
config-flow-test-coverage: done config-flow-test-coverage: done
config-flow: config-flow: done
status: done
comment: |
PLANNED: Remove _LOGGER.error call from config_flow.py — the error
message is redundant with the errors dict entry.
dependency-transparency: done dependency-transparency: done
docs-actions: docs-actions:
status: exempt status: exempt
@@ -40,11 +36,7 @@ rules:
docs-installation-parameters: done docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: log-when-unavailable: done
status: done
comment: |
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
passed to UpdateFailed, so logging it separately is redundant.
parallel-updates: done parallel-updates: done
reauthentication-flow: todo reauthentication-flow: todo
test-coverage: test-coverage:
@@ -74,11 +66,7 @@ rules:
comment: All entities are core functionality. comment: All entities are core functionality.
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: todo
icon-translations: icon-translations: done
status: done
comment: |
PLANNED: Remove the icon property from climate.py — entities should not set
custom icons. Use HA defaults or icon translations instead.
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:
status: exempt status: exempt

View File

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

View File

@@ -21,11 +21,17 @@ from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
from .entity import HypontechEntity, HypontechPlantEntity from .entity import HypontechEntity, HypontechPlantEntity
def _power_unit(data: OverviewData | PlantData) -> str:
"""Return the unit of measurement for power based on the API unit."""
return UnitOfPower.KILO_WATT if data.company.upper() == "KW" else UnitOfPower.WATT
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class HypontechSensorDescription(SensorEntityDescription): class HypontechSensorDescription(SensorEntityDescription):
"""Describes Hypontech overview sensor entity.""" """Describes Hypontech overview sensor entity."""
value_fn: Callable[[OverviewData], float | None] value_fn: Callable[[OverviewData], float | None]
unit_fn: Callable[[OverviewData], str] | None = None
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -33,15 +39,16 @@ class HypontechPlantSensorDescription(SensorEntityDescription):
"""Describes Hypontech plant sensor entity.""" """Describes Hypontech plant sensor entity."""
value_fn: Callable[[PlantData], float | None] value_fn: Callable[[PlantData], float | None]
unit_fn: Callable[[PlantData], str] | None = None
OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
HypontechSensorDescription( HypontechSensorDescription(
key="pv_power", key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.power, value_fn=lambda c: c.power,
unit_fn=_power_unit,
), ),
HypontechSensorDescription( HypontechSensorDescription(
key="lifetime_energy", key="lifetime_energy",
@@ -64,10 +71,10 @@ OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
HypontechPlantSensorDescription( HypontechPlantSensorDescription(
key="pv_power", key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.power, value_fn=lambda c: c.power,
unit_fn=_power_unit,
), ),
HypontechPlantSensorDescription( HypontechPlantSensorDescription(
key="lifetime_energy", key="lifetime_energy",
@@ -124,6 +131,13 @@ class HypontechOverviewSensor(HypontechEntity, SensorEntity):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.account_id}_{description.key}" self._attr_unique_id = f"{coordinator.account_id}_{description.key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if self.entity_description.unit_fn is not None:
return self.entity_description.unit_fn(self.coordinator.data.overview)
return super().native_unit_of_measurement
@property @property
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
@@ -146,6 +160,13 @@ class HypontechPlantSensor(HypontechPlantEntity, SensorEntity):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{plant_id}_{description.key}" self._attr_unique_id = f"{plant_id}_{description.key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if self.entity_description.unit_fn is not None:
return self.entity_description.unit_fn(self.plant)
return super().native_unit_of_measurement
@property @property
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""

View File

@@ -22,7 +22,13 @@
"description": "Authenticate against IntelliFire cloud" "description": "Authenticate against IntelliFire cloud"
}, },
"pick_cloud_device": { "pick_cloud_device": {
"description": "Select fireplace by serial number:", "data": {
"serial": "Fireplace serial number"
},
"data_description": {
"serial": "Serial number of the fireplace to configure"
},
"description": "Select fireplace by serial number.",
"title": "Configure fireplace" "title": "Configure fireplace"
} }
} }
@@ -159,6 +165,10 @@
"control_mode": "Send commands to", "control_mode": "Send commands to",
"read_mode": "Read data from" "read_mode": "Read data from"
}, },
"data_description": {
"control_mode": "Whether to send fireplace commands via the `Local` or `Cloud` API",
"read_mode": "Whether to read fireplace state via the `Local` or `Cloud` API"
},
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.", "description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
"title": "Endpoint selection" "title": "Endpoint selection"
} }

View File

@@ -151,7 +151,9 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
return value return value
def get_options_map(self, command: str) -> dict[str, str]: def get_options_map(
self, command: str, *, snake_case: bool = False
) -> dict[str, str]:
"""Get the available options for a command.""" """Get the available options for a command."""
capabilities = self.capabilities.get(command, {}) capabilities = self.capabilities.get(command, {})
@@ -162,7 +164,10 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
values = list(capabilities.get("parameter", {}).get("read", {}).values()) values = list(capabilities.get("parameter", {}).get("read", {}).values())
return {v: v.translate(TRANSLATIONS) for v in values} options = {v: v.translate(TRANSLATIONS) for v in values}
if snake_case:
return {k: v.replace("-", "_") for k, v in options.items()}
return options
def supports(self, command: type[Command]) -> bool: def supports(self, command: type[Command]) -> bool:
"""Check if the device supports a command.""" """Check if the device supports a command."""

View File

@@ -18,6 +18,9 @@
"dynamic_control": { "dynamic_control": {
"default": "mdi:lightbulb-on-outline" "default": "mdi:lightbulb-on-outline"
}, },
"hdr_processing": {
"default": "mdi:image-filter-hdr-outline"
},
"input": { "input": {
"default": "mdi:hdmi-port" "default": "mdi:hdmi-port"
}, },
@@ -26,6 +29,9 @@
}, },
"light_power": { "light_power": {
"default": "mdi:lightbulb-on-outline" "default": "mdi:lightbulb-on-outline"
},
"picture_mode": {
"default": "mdi:movie-roll"
} }
}, },
"sensor": { "sensor": {

View File

@@ -20,6 +20,7 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
"""Describes JVC Projector select entities.""" """Describes JVC Projector select entities."""
command: type[Command] command: type[Command]
snake_case_states: bool = False
SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = ( SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
@@ -49,6 +50,18 @@ SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
command=cmd.Anamorphic, command=cmd.Anamorphic,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
JvcProjectorSelectDescription(
key="hdr_processing",
command=cmd.HdrProcessing,
entity_registry_enabled_default=False,
snake_case_states=True,
),
JvcProjectorSelectDescription(
key="picture_mode",
command=cmd.PictureMode,
entity_registry_enabled_default=False,
snake_case_states=True,
),
) )
@@ -84,7 +97,8 @@ class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity):
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._options_map: dict[str, str] = coordinator.get_options_map( self._options_map: dict[str, str] = coordinator.get_options_map(
self.command.name self.command.name,
snake_case=description.snake_case_states,
) )
@property @property

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