Compare commits

...

133 Commits

Author SHA1 Message Date
Robert Resch 03eb59026f Documented circular import 2026-05-26 13:54:44 +00:00
Robert Resch db181978f3 Circular import is gone 2026-05-26 09:51:11 +00:00
Robert Resch 325531bdf2 Implement review findings 2026-05-26 08:26:51 +00:00
Robert Resch 07dee1293d Fix tests 2026-05-21 20:01:04 +00:00
Robert Resch 6e497cb95d Address review comments 2026-05-21 19:39:10 +00:00
Robert Resch df01a9208d Fix circular import 2026-05-19 11:58:37 +00:00
Robert Resch 60956c110b Migrate http config to ui 2026-05-18 17:28:16 +00:00
Paulus Schoutsen af41b704d5 Add shared test mocks for the Infrared integration (#170296)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 09:46:09 +02:00
Crocmagnon d5f2cd8b17 data grand lyon: implement reconfiguration flow (#170210) 2026-05-11 09:17:31 +02:00
Paulus Schoutsen f96afda959 Fix spelling of RS-232 in Denon RS-232 integration (#170298) 2026-05-11 07:30:16 +02:00
Paulus Schoutsen 94bf13c6bb Fix flaky diagnostics test for Novy Cooker Hood (#170299) 2026-05-11 07:29:51 +02:00
iluvdata b7dca79743 Add RepairsFlowResult TypeAlias (#170263) 2026-05-10 23:42:47 -04:00
Mick Vleeshouwer df84d7a32d Fix local API incorrectly marking devices as unavailable in Overkiz (#170118)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2026-05-10 23:40:13 -04:00
Christian Lackas c217acd7ab Skip offline devices instead of aborting ViCare diagnostics (#170106) 2026-05-10 23:31:10 -04:00
Abílio Costa f008f1501f Fix deprecated_class to work with inheritance (#170137) 2026-05-10 23:28:19 -04:00
Michael 739a5780b7 Add diagnostics platform to Home Assistant Connect ZBT-2 (#170158) 2026-05-10 23:27:13 -04:00
Willem-Jan van Rootselaar 0ef221611a Handle empty BSB-LAN heating circuits (#170249) 2026-05-10 23:25:55 -04:00
Crocmagnon 59e04c2169 data grand lyon: use test fixture instead of manual patching (#170294) 2026-05-10 22:08:06 -04:00
renovate[bot] 5b0bf09fdc Update infrared-protocols to 4.0.0 (#170297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 22:07:13 -04:00
puddly c07d176467 Set serial port description from description, not product (#170160)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-10 22:06:04 -04:00
Crocmagnon c39f0127ca data grand lyon: implement diagnostics (#170184) 2026-05-10 21:09:48 -04:00
Heikki Henriksen cdf5d39f57 prusalink: add continue-job button for ATTENTION state (#170193)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:22 -04:00
Sören 90b6aa4d91 Use standby for HDMI-CEC turn off (#170206) 2026-05-10 20:53:17 -04:00
Florent Thoumie f8ebc6c1e2 iaqualink: set system specific polling interval (#170279) 2026-05-10 20:07:13 -04:00
A. Gideonse e4b4503c10 Apply strict typing to Indevolt integration (#170288) 2026-05-10 20:06:22 -04:00
A. Gideonse 7db1c855c1 Align Indevolt quality scale with documentation (#170289) 2026-05-10 20:06:01 -04:00
A. Gideonse aa45f90a87 Complete icon translations for Indevolt (#170292) 2026-05-10 20:00:20 -04:00
Sören Beye cd945a42e6 fix: Do not forget segments from state when a new config arrives (#170265)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-10 22:17:52 +02:00
Ronald van der Meer afc97268de Migrate Duco to python-duco-connectivity and remove temperature sensors (#170237) 2026-05-10 13:09:46 -04:00
Heikki Henriksen 497faeb103 Use VERSION from PrusaLinkConfigFlow in prusalink (#170200)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:51:02 +01:00
Luka Matijević 84625678d3 Bump qbittorrent-api to 2026.5.1 (#170181) 2026-05-10 17:44:46 +01:00
Perry Naseck 95daee9f07 Update upb-lib to 0.7.0 (#170051) 2026-05-10 17:42:35 +01:00
Simone Chemelli ff1552e317 Bump aioamazondevices to 13.5.0 (#170258) 2026-05-10 17:40:36 +01:00
Pascal Brunot ff6b69c929 Bump serialx to 1.7.2 (#170272) 2026-05-10 17:39:04 +01:00
Maciej Bieniek 2a74d5a81c Bump imgw-pib to 2.1.2 (#170274) 2026-05-10 17:37:48 +01:00
Michael 52237247ae Migrate Sensirion BLE to config entry runtime data (#170227)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-05-10 17:46:12 +02:00
Michael 62d958682a Migrate Thermo Beacon to config entry runtime data (#170226) 2026-05-10 16:46:13 +02:00
Joris Pelgröm b2dad41d35 Bump letpot to 0.7.0 (#169868) 2026-05-10 11:39:17 +02:00
Karl Beecken 83c5dbb111 Add more Gen1 sensors to indevolt (#170191) 2026-05-10 11:37:46 +02:00
Klaas Schoute cf73ef8a20 Update easyEnergy exception and icon translations (#170218) 2026-05-10 08:19:18 +02:00
Simone Chemelli 6555db12b1 Fix entities refresh for UptimeRobot (#170217) 2026-05-10 08:14:03 +02:00
Jan-Philipp Benecke 20b81e9c74 Bump aiotankerkoenig to 0.5.3 (#170224) 2026-05-10 08:12:27 +02:00
Yogev Kriger 51d004a5bb Require admin for webhook list websocket command (#170216) 2026-05-09 21:23:42 +02:00
Michael 9c9b626ade Use has_entity_name for all entities in FRITZ!SmartHome integration (#170199) 2026-05-09 19:01:51 +02:00
Klaas Schoute e0d3eb0fe3 Add config flow connection check to easyEnergy integration (#170207) 2026-05-09 17:18:27 +02:00
Øyvind Matheson Wergeland 5f5df558c6 Drop redundant HVAC mode validation in nobo_hub (#170140) 2026-05-09 16:28:46 +02:00
Klaas Schoute fbc5884ce8 Update easyEnergy integration to v3.0.1 (#170201) 2026-05-09 15:59:35 +02:00
Crocmagnon e72346c222 data grand lyon: exception translations (#170188) 2026-05-09 15:44:56 +02:00
Jan Bouwhuis 266f7b8dbe Fix overkiz snapshots (#170196) 2026-05-09 14:49:19 +02:00
Crocmagnon 3ae4811e99 data grand lyon: icon translations (#170189) 2026-05-09 14:28:15 +02:00
Crocmagnon 526ed271ae data grand lyon: mark repair-issues as exempt (#170194) 2026-05-09 14:26:32 +02:00
renovate[bot] 6c823cd970 Update infrared-protocols to 3.5.0 (#170169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 13:05:54 +02:00
Paul Bottein fb4b36b7f0 Add reconfigure flow to Novy Cooker Hood (#169410) 2026-05-09 10:27:27 +02:00
Paul Bottein 86898f9111 Add diagnostics to Novy Cooker Hood (#169891) 2026-05-09 08:38:58 +02:00
Abílio Costa 27969c34a5 Stop using make_command in LG Infrared (#170149) 2026-05-09 00:24:51 +03:00
Mick Vleeshouwer 74fabca890 Add button entity tests to Overkiz (#170122) 2026-05-09 00:24:37 +03:00
Crocmagnon af6fcae8b6 data grand lyon: update quality scale in manifest (#170109) 2026-05-08 23:04:47 +02:00
Thijs W. 818b420cb5 Update afsapi to 1.0.1 (#170073) 2026-05-08 23:02:43 +02:00
Øyvind Matheson Wergeland ef2a065784 Use suggested_display_precision in nobo_hub temperature sensor (#170138) 2026-05-08 21:03:25 +02:00
Mick Vleeshouwer 15943a737a Fix is_closed state for DynamicGate covers in Overkiz (#170130) 2026-05-08 20:42:50 +02:00
Heikki Henriksen 1647c0bf84 Bump pyprusalink to 2.2.0 (#170105)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:26:52 +02:00
bkobus-bbx 42aefd67dd Bump blebox_uniapi to v2.5.3 (#170115) 2026-05-08 16:21:54 +02:00
Mick Vleeshouwer c281c51fc9 Set is_closed state to None when a cover state returns "unknown" in Overkiz (#170081) 2026-05-08 16:20:10 +02:00
Robert Svensson fa09c6d29a Fix websocket certificate verification Bump axis to v70 (#170038) 2026-05-08 16:18:24 +02:00
Ronald van der Meer 9f7ddcca22 Add system health platform for Duco integration (#169517) 2026-05-08 13:25:28 +02:00
Willem-Jan van Rootselaar e488c7f3a5 Bump python-bsblan to 5.2.1 (#170100) 2026-05-08 13:17:47 +02:00
wollew bb924e79b1 Speed up Velux setup by avoiding disconnect from gateway (#167932)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 12:57:48 +02:00
Øyvind Matheson Wergeland 39d60faa42 Add DHCP discovery to nobo_hub (#169595)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:57:05 +02:00
Mick Vleeshouwer 378a26f778 Add number entity tests to Overkiz (#170098) 2026-05-08 12:23:07 +02:00
Mick Vleeshouwer 5c12d59ab7 Fix tilt support for UpDownVenetianBlind (rts:VenetianBlindRTSComponent) in Overkiz (#170047) 2026-05-08 12:22:23 +02:00
TheJulianJES c9e44d2d51 Bump ZHA to 1.3.1 (#170095) 2026-05-08 12:18:19 +02:00
Heikki Henriksen c195ddd8f2 prusalink: extract PrusaLinkEntityDescription base class (#170092)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:11:22 +02:00
Mick Vleeshouwer 4e388e1435 Fix cover controls for UpDownBioclimaticPergola in Overkiz (#170058) 2026-05-08 12:09:04 +02:00
Heikki Henriksen 191143d12d prusalink: expose printer location as suggested_area (#170099)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:08:09 +02:00
Mick Vleeshouwer bb6087cf87 Fix tilt controls for TiltOnlyVenetianBlind in Overkiz (#170055)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 11:57:37 +02:00
Rob Treacy 70e18fc196 Fix WiZ Light config flow timeout by properly closing UDP connections (#168456) 2026-05-08 10:35:13 +02:00
TheJulianJES 526ddc4770 Fix Z-Wave discovery crash with unknown node firmware version (#170090) 2026-05-08 10:21:39 +02:00
Mick Vleeshouwer 5f6bd9b6a7 Fix sensors getting wrong unit from MeasuredValueType attribute in Overkiz (#170088) 2026-05-08 10:15:20 +02:00
Maciej Bieniek 9b525bf1cb Use SensorDeviceClass.UPTIME in System Monitor (#170084) 2026-05-08 10:13:15 +02:00
Maciej Bieniek 3bc2c0d097 Use SensorDeviceClass.UPTIME in Unifi (#170087) 2026-05-08 10:12:57 +02:00
Mattie b5bdff7068 Add water_heater platform to Qube heat pump (#169851) 2026-05-08 10:12:26 +02:00
Erwin Douna 7103b07638 Portainer refactor tests to use enums from pyportainer (#170044) 2026-05-08 09:05:36 +02:00
Mick Vleeshouwer d52c281826 Fix is_closed state for DynamicGarageDoor in Overkiz (#170052) 2026-05-08 09:04:49 +02:00
Crocmagnon 9fca2f284b data grand lyon: implement reauth (#170059) 2026-05-08 09:04:16 +02:00
dependabot[bot] f1986d5fc3 Bump github/codeql-action from 4.35.2 to 4.35.3 (#170077) 2026-05-08 08:47:31 +02:00
Thomas Bouron ce9c83e33c Add fixture for Tuya pool heating pump (#170064) 2026-05-08 07:33:59 +02:00
renovate[bot] aa98fce92e Update infrared-protocols to 3.2.0 (#170070)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:11:27 +02:00
Robert Resch b01e56582a Bump deebot-client to 18.3.0 (#170066) 2026-05-08 01:43:54 +02:00
Mick Vleeshouwer 9be078475d Bump pyOverkiz to 1.20.3 (#170060) 2026-05-08 01:23:43 +02:00
Ronald van der Meer 9174ae4e00 Bump python-duco-client to 0.5.0 (#170065) 2026-05-08 01:10:50 +02:00
th3spis d4aa1b53f2 Added wfsens as a occupancy source in wiz (#166799)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 00:06:23 +02:00
mayerwin ba29f210c2 Translate switchbot_cloud library errors to HomeAssistantError (#169715)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 00:01:49 +02:00
Joost Lekkerkerker 845572927c Fix CI (#170061) 2026-05-07 22:51:44 +01:00
MoonDevLT 9cd7ac2722 Add sensor entity to lunatone integration (#167873)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-07 23:34:48 +02:00
Muhammad Ihsan a7fd763570 Add Cielo Home integration (#158511)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Owais Amin <141307092+owais-cielo@users.noreply.github.com>
Co-authored-by: Owais Amin <owais@cielowigle.com>
Co-authored-by: Maria Nadeem <maria@cielowigle.com>
2026-05-07 23:12:19 +02:00
theobld-ww 65491372c2 Bump visionpluspython 1.0.2 to 1.1.0 (#169842)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-07 22:41:16 +02:00
Mattie de96ee44e5 Add switch platform to Qube heat pump (#169407) 2026-05-07 22:40:37 +02:00
Crocmagnon 6edcf5722e Add Data Grand Lyon integration (#167946)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 22:12:57 +02:00
Jeef e6acebb322 Fix IntelliFire setup recovery (#169739) 2026-05-07 21:55:17 +02:00
Christian Lackas 277daf2dba vicare: migrate to OAuth2 with application credentials (#165621)
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-05-07 21:23:46 +02:00
Paulus Schoutsen 1b935314f8 Represent ThinQ hoods as fans instead of number entities (#159601)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-07 21:17:01 +02:00
G Johansson cad5c9e8fa Remove advanced mode from dnsip (#170040) 2026-05-07 21:03:15 +02:00
Midori Kochiya f7201f1910 Bump xiaomi-ble to 1.11.0 (#170018) 2026-05-07 19:34:12 +01:00
Glenn Waters c406e1aeed ElkM1 integration: Add time entity for settings (#170035) 2026-05-07 20:17:42 +02:00
G Johansson 946a3bcf11 Add missing areas in Nord Pool services (#169752) 2026-05-07 20:10:02 +02:00
Erwin Douna 2c8d9c7207 Add disk space coordinator for Portainer (#165855) 2026-05-07 20:05:29 +02:00
Michael db25f1911e Proper handling of malformed data during FRITZ!Box Tools setup (#170030) 2026-05-07 19:59:06 +02:00
Yevhenii Vaskivskyi 7e2fa90773 Remove Advanced mode from asuswrt (#170029) 2026-05-07 19:54:12 +02:00
Felipe Santos ef83ccc423 Allow selecting input source on SmartThings TVs (#160034)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-05-07 18:53:38 +01:00
Ronald van der Meer 046b48df43 Bump python-duco-client to 0.4.2 (#170027) 2026-05-07 19:51:54 +02:00
epenet 66cd719f85 Fix KeyError in hydrawise (#169853) 2026-05-07 18:50:29 +01:00
renovate[bot] b0c2e57649 Update infrared-protocols to 3.1.0 (#169968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2026-05-07 18:26:33 +01:00
Glenn Waters cb92fa27ba Add number entity to ElkM1 integration (#169861) 2026-05-07 17:39:30 +02:00
Erik Montnemery c3f8f6f310 Use modern API in condition tests (#170002) 2026-05-07 17:33:00 +02:00
Tomasz Dylewski a82205fed7 Added PAJ GPS integration (#165070)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-07 17:04:19 +02:00
epenet 776fd69e39 Use SensorDeviceClass.ENUM in Tuya sensors (#169987) 2026-05-07 17:02:31 +02:00
Christian Lackas 2863b59be4 Bump homematicip to 2.11.0 (#170005) 2026-05-07 16:58:13 +02:00
epenet 676e9c7f29 Migrate Cast to use runtime_data (#168856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:57:29 +02:00
Petro31 05c3c058d6 Remove legacy alarm control panel template entities (#169608)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 15:54:59 +02:00
Petro31 fd93f24208 Remove legacy binary sensor template entities (#169610) 2026-05-07 15:52:43 +02:00
Petro31 544b21f014 Remove legacy cover template entities (#169611) 2026-05-07 15:51:41 +02:00
Petro31 8d30abab9e Remove legacy fan template entities (#169613) 2026-05-07 15:51:08 +02:00
Petro31 ee19c11565 Remove legacy lock template entities (#169725) 2026-05-07 15:50:22 +02:00
Heikki Henriksen c26eb2374d prusalink: add X/Y axis, location, and min extrusion temp sensors (#169312)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 15:39:07 +02:00
Kamil Breguła 59bc46a9d2 Fix Tuya siren entity naming to avoid incorrect main entity assignment (#170008)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-07 15:24:37 +02:00
Petro31 ab668ac576 Remove legacy sensor template entities (#169728) 2026-05-07 15:22:22 +02:00
Petro31 c4836600c4 Remove legacy vacuum template entities (#169732) 2026-05-07 15:18:45 +02:00
Petro31 f4e0349825 Remove legacy light template entities (#169615) 2026-05-07 15:00:39 +02:00
Petro31 4d578b6c98 Remove legacy switch template entities (#169730) 2026-05-07 14:58:27 +02:00
chiro79 741779efd7 Remove name field from pvpc_hourly_pricing config flow #168955 (#169998) 2026-05-07 14:34:31 +02:00
Erik Montnemery eb1babedfd Improve condition docstrings (#170000)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 14:33:37 +02:00
Aidan Timson de0d24e91c Add default icon translations for lg_infrared (#170004) 2026-05-07 14:21:11 +02:00
Jan Bouwhuis 0de23f2636 Remove not used None defaults on MQTT publish API (#169936) 2026-05-07 13:53:29 +02:00
443 changed files with 25715 additions and 3952 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: "/language:python"
+3
View File
@@ -155,6 +155,7 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grand_lyon.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
@@ -295,6 +296,7 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.indevolt.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
@@ -423,6 +425,7 @@ homeassistant.components.otp.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.*
Generated
+6
View File
@@ -294,6 +294,8 @@ CLAUDE.md @home-assistant/core
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -345,6 +347,8 @@ CLAUDE.md @home-assistant/core
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/data_grand_lyon/ @Crocmagnon
/tests/components/data_grand_lyon/ @Crocmagnon
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
@@ -1308,6 +1312,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
/tests/components/p1_monitor/ @klaasnicolaas
/homeassistant/components/paj_gps/ @skipperro
/tests/components/paj_gps/ @skipperro
/homeassistant/components/palazzetti/ @dotvav
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
+2 -7
View File
@@ -46,7 +46,7 @@ from .components import (
file_upload as file_upload_pre_import, # noqa: F401
group as group_pre_import, # noqa: F401
history as history_pre_import, # noqa: F401
http, # not named pre_import since it has requirements
http as http_import, # noqa: F401 - not named pre_import since it has requirements
image_upload as image_upload_import, # noqa: F401 - not named pre_import since it has requirements
logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401
@@ -403,12 +403,7 @@ async def async_setup_hass(
_LOGGER.info("Starting in recovery mode")
hass.config.recovery_mode = True
http_conf = (await http.async_get_last_config(hass)) or {}
await async_from_config_dict(
{"recovery_mode": {}, "http": http_conf},
hass,
)
await async_from_config_dict({"recovery_mode": {}}, hass)
if runtime_config.open_ui:
hass.add_job(open_hass_ui, hass)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.7.2"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.3"]
"requirements": ["aioamazondevices==13.5.0"]
}
@@ -7,8 +7,7 @@ import anthropic
from anthropic.resources.messages.messages import DEPRECATED_MODELS
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -41,9 +40,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._current_subentry_id = None
self._model_list_cache = None
async def async_step_init(
self, user_input: dict[str, str]
) -> data_entry_flow.FlowResult:
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
"""Handle the steps of a fix flow."""
if user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
@@ -5,8 +5,7 @@ from typing import cast
import voluptuous as vol
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
from homeassistant.components.repairs import RepairsFlow
from homeassistant.data_entry_flow import FlowResult
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.helpers import entity_registry as er
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
@@ -21,14 +20,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
raise ValueError("Missing data")
self._data = data
async def async_step_init(self, _: None = None) -> FlowResult:
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_disable_entity()
async def async_step_confirm_disable_entity(
self,
user_input: dict[str, str] | None = None,
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entity_registry = er.async_get(self.hass)
@@ -30,7 +30,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.typing import VolDictType
from .bridge import AsusWrtBridge
from .const import (
@@ -142,20 +141,12 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
user_input = self._config_data
add_schema: VolDictType
if self.show_advanced_options:
add_schema = {
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
vol.Optional(CONF_PORT): cv.port,
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
}
else:
add_schema = {vol.Required(CONF_PASSWORD): str}
schema = {
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str,
**add_schema,
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
vol.Optional(CONF_PORT): cv.port,
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
vol.Required(
CONF_PROTOCOL,
default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS),
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==70"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.2"],
"requirements": ["blebox-uniapi==2.5.3"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+38 -4
View File
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
DEFAULT_HEATING_CIRCUITS
)
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = [1]
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"defaulting to a single circuit. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
if not circuits:
LOGGER.warning(
"Circuit discovery during migration returned no heating circuits "
"for %s; defaulting to a single circuit",
entry.data[CONF_HOST],
)
circuits = list(DEFAULT_HEATING_CIRCUITS)
hass.config_entries.async_update_entry(
entry,
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
circuits,
)
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
# discovery. Every BSB-LAN setup has at least one heating circuit.
if entry.version == 1 and entry.minor_version < 3:
if not entry.data[CONF_HEATING_CIRCUITS]:
LOGGER.warning(
"Stored heating circuits for %s are empty; defaulting to a "
"single circuit",
entry.data[CONF_HOST],
)
data = {
**entry.data,
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
}
else:
data = {**entry.data}
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
return True
+18 -4
View File
@@ -13,21 +13,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = [1]
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -384,6 +391,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -392,4 +406,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
+1
View File
@@ -22,4 +22,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.2.0"],
"requirements": ["python-bsblan==5.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",
+3 -4
View File
@@ -2,8 +2,7 @@
from typing import Any
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -21,13 +20,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the initial step of the repair flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle confirmation, remove the bindkey, and reload the entry."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self._entry_id)
+37 -19
View File
@@ -1,9 +1,12 @@
"""Component to embed Google Cast."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
from pychromecast import Chromecast
from pychromecast.controllers.multizone import MultizoneManager
from pychromecast.discovery import CastBrowser
from homeassistant.components.media_player import BrowseMedia, MediaType
from homeassistant.config_entries import ConfigEntry
@@ -20,12 +23,41 @@ from .const import DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
type CastConfigEntry = ConfigEntry[CastRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class CastRuntimeData:
"""Runtime data for the Cast integration."""
cast_platforms: dict[str, CastProtocol] = field(default_factory=dict)
unknown_models: dict[str | None, tuple[str | None, str | None]] = field(
default_factory=dict
)
added_cast_devices: set[UUID] = field(default_factory=set)
browser: CastBrowser | None = None
multizone_manager: MultizoneManager | None = None
async def async_setup_entry(hass: HomeAssistant, entry: CastConfigEntry) -> bool:
"""Set up Cast from a config entry."""
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
entry.runtime_data = CastRuntimeData()
await home_assistant_cast.async_setup_ha_cast(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
) -> None:
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
entry.runtime_data.cast_platforms[integration_domain] = platform
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
return True
@@ -65,27 +97,13 @@ class CastProtocol(Protocol):
"""
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
):
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
hass.data[DOMAIN]["cast_platform"][integration_domain] = platform
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
await home_assistant_cast.async_remove_user(hass, entry)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove cast config entry from a device.
+6 -8
View File
@@ -1,16 +1,11 @@
"""Config flow for Cast."""
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -19,6 +14,9 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
@@ -40,7 +38,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
) -> CastOptionsFlowHandler:
"""Get the options flow for this handler."""
return CastOptionsFlowHandler()
-7
View File
@@ -12,13 +12,6 @@ DOMAIN = "cast"
# Stores a threading.Lock that is held by the internal pychromecast discovery.
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Store a CastBrowser
CAST_BROWSER_KEY = "cast_browser"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
+19 -11
View File
@@ -2,17 +2,16 @@
import logging
import threading
from typing import TYPE_CHECKING
import pychromecast.discovery
import pychromecast.models
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
CAST_BROWSER_KEY,
CONF_KNOWN_HOSTS,
INTERNAL_DISCOVERY_RUNNING_KEY,
SIGNAL_CAST_DISCOVERED,
@@ -20,11 +19,16 @@ from .const import (
)
from .helpers import ChromecastInfo, ChromeCastZeroconf
if TYPE_CHECKING:
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
def discover_chromecast(
hass: HomeAssistant, cast_info: pychromecast.models.CastInfo
hass: HomeAssistant,
cast_info: pychromecast.models.CastInfo,
config_entry: CastConfigEntry,
) -> None:
"""Discover a Chromecast."""
@@ -36,7 +40,7 @@ def discover_chromecast(
_LOGGER.error("Discovered chromecast without uuid %s", info)
return
info = info.fill_out_missing_chromecast_info(hass)
info = info.fill_out_missing_chromecast_info(hass, config_entry)
_LOGGER.debug("Discovered new or updated chromecast %s", info)
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
@@ -49,7 +53,9 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def setup_internal_discovery(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
@@ -63,11 +69,11 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
def add_cast(self, uuid, _):
"""Handle zeroconf discovery of a new chromecast."""
discover_chromecast(hass, browser.devices[uuid])
discover_chromecast(hass, browser.devices[uuid], config_entry)
def update_cast(self, uuid, _):
"""Handle zeroconf discovery of an updated chromecast."""
discover_chromecast(hass, browser.devices[uuid])
discover_chromecast(hass, browser.devices[uuid], config_entry)
def remove_cast(self, uuid, service, cast_info):
"""Handle zeroconf discovery of a removed chromecast."""
@@ -84,7 +90,7 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
ChromeCastZeroconf.get_zeroconf(),
config_entry.data.get(CONF_KNOWN_HOSTS),
)
hass.data[CAST_BROWSER_KEY] = browser
config_entry.runtime_data.browser = browser
browser.start_discovery()
def stop_discovery(event):
@@ -98,7 +104,9 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.add_update_listener(config_entry_updated)
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
async def config_entry_updated(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Handle config entry being updated."""
browser = hass.data[CAST_BROWSER_KEY]
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
if browser := config_entry.runtime_data.browser:
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
+6 -6
View File
@@ -20,11 +20,11 @@ import pychromecast.socket_client
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
if TYPE_CHECKING:
from homeassistant.components import zeroconf
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -56,16 +56,16 @@ class ChromecastInfo:
"""Return the UUID."""
return self.cast_info.uuid
def fill_out_missing_chromecast_info(self, hass: HomeAssistant) -> ChromecastInfo:
def fill_out_missing_chromecast_info(
self, hass: HomeAssistant, config_entry: CastConfigEntry
) -> ChromecastInfo:
"""Return a new ChromecastInfo object with missing attributes filled in.
Uses blocking HTTP / HTTPS.
"""
cast_info = self.cast_info
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
unknown_models = hass.data[DOMAIN]["unknown_models"]
unknown_models = config_entry.runtime_data.unknown_models
if self.cast_info.model_name not in unknown_models:
# Manufacturer and cast type is not available in mDNS data,
# get it over HTTP
@@ -1,8 +1,10 @@
"""Home Assistant Cast integration for Cast."""
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant import auth, config_entries, core
from homeassistant import auth, core
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
@@ -11,6 +13,9 @@ from homeassistant.helpers.service import async_register_admin_service
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
if TYPE_CHECKING:
from . import CastConfigEntry
SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path"
ATTR_URL_PATH = "dashboard_path"
@@ -21,9 +26,7 @@ NO_URL_AVAILABLE_ERROR = (
)
async def async_setup_ha_cast(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Set up Home Assistant Cast."""
user_id: str | None = entry.data.get("user_id")
user: auth.models.User | None = None
@@ -87,9 +90,7 @@ async def async_setup_ha_cast(
)
async def async_remove_user(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
user_id: str | None = entry.data.get("user_id")
+34 -25
View File
@@ -1,5 +1,4 @@
"""Provide functionality to interact with Cast devices on the network."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections.abc import Callable
from contextlib import suppress
@@ -42,7 +41,6 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
CONF_UUID,
@@ -58,8 +56,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
ADDED_CAST_DEVICES_KEY,
CAST_MULTIZONE_MANAGER_KEY,
CONF_IGNORE_CEC,
DOMAIN,
SIGNAL_CAST_DISCOVERED,
@@ -78,7 +74,7 @@ from .helpers import (
)
if TYPE_CHECKING:
from . import CastProtocol
from . import CastConfigEntry, CastProtocol
_LOGGER = logging.getLogger(__name__)
@@ -110,7 +106,9 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
@callback
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
def _async_create_cast_device(
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
):
"""Create a CastDevice entity or dynamic group from the chromecast object.
Returns None if the cast device has already been added.
@@ -121,7 +119,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
return None
# Found a cast with UUID
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
added_casts = config_entry.runtime_data.added_cast_devices
if info.uuid in added_casts:
# Already added this one, the entity will take care of moved hosts
# itself
@@ -131,21 +129,19 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
if info.is_dynamic_group:
# This is a dynamic group, do not add it but connect to the service.
group = DynamicCastGroup(hass, info)
group = DynamicCastGroup(hass, config_entry, info)
group.async_setup()
return None
return CastMediaPlayerEntity(hass, info)
return CastMediaPlayerEntity(hass, config_entry, info)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cast from a config entry."""
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
@@ -160,7 +156,7 @@ async def async_setup_entry(
# UUID not matching, ignore.
return
cast_device = _async_create_cast_device(hass, discover)
cast_device = _async_create_cast_device(hass, config_entry, discover)
if cast_device is not None:
async_add_entities([cast_device])
@@ -179,13 +175,19 @@ class CastDevice:
_mz_only: bool
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
self.hass: HomeAssistant = hass
self._config_entry = config_entry
self._cast_info = cast_info
self._chromecast: pychromecast.Chromecast | None = None
self.mz_mgr = None
self.mz_mgr: MultizoneManager | None = None
self._status_listener: CastStatusListener | None = None
self._add_remove_handler: Callable[[], None] | None = None
self._del_remove_handler: Callable[[], None] | None = None
@@ -214,7 +216,9 @@ class CastDevice:
if self._cast_info.uuid is not None:
# Remove the entity from the added casts so that it can dynamically
# be re-added again.
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
self._config_entry.runtime_data.added_cast_devices.remove(
self._cast_info.uuid
)
if self._add_remove_handler:
self._add_remove_handler()
self._add_remove_handler = None
@@ -237,10 +241,10 @@ class CastDevice:
)
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
runtime_data = self._config_entry.runtime_data
if runtime_data.multizone_manager is None:
runtime_data.multizone_manager = MultizoneManager()
self.mz_mgr = runtime_data.multizone_manager
self._status_listener = CastStatusListener(
self, chromecast, self.mz_mgr, self._mz_only
@@ -300,10 +304,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
_attr_media_image_remotely_accessible = True
_mz_only = False
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
CastDevice.__init__(self, hass, cast_info)
CastDevice.__init__(self, hass, config_entry, cast_info)
self.cast_status = None
self.media_status = None
@@ -592,7 +601,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
"""Generate root node."""
children = []
# Add media browsers
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
children.extend(
await platform.async_get_media_browser_root_object(
self.hass, self._chromecast.cast_type
@@ -651,7 +660,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
platform: CastProtocol
assert media_content_type is not None
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
browse_media = await platform.async_browse_media(
self.hass,
media_content_type,
@@ -713,7 +722,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return
# Try the cast platforms
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
result = await platform.async_play_media(
self.hass, self.entity_id, chromecast, media_type, media_id
)
@@ -0,0 +1,24 @@
"""Integration for Cielo Home."""
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
async def async_setup_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool:
"""Set up Cielo Home from a config entry."""
coordinator = CieloDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = entry.runtime_data
await coordinator.async_shutdown()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,311 @@
"""Support for Cielo home thermostats and Smart AC Controllers."""
import asyncio
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate, ParamSpec, TypeVar
from cieloconnectapi.exceptions import AuthenticationError
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CIELO_ERRORS, LOGGER, TIMEOUT
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
from .entity import CieloDeviceEntity
_T = TypeVar("_T", bound="CieloDeviceEntity")
_P = ParamSpec("_P")
PARALLEL_UPDATES = 0
CIELO_TO_HA_HVAC: dict[str, HVACMode] = {
"cool": HVACMode.COOL,
"heat": HVACMode.HEAT,
"fan": HVACMode.FAN_ONLY,
"dry": HVACMode.DRY,
"auto": HVACMode.AUTO,
"heat_cool": HVACMode.HEAT_COOL,
"off": HVACMode.OFF,
}
HA_TO_CIELO_HVAC: dict[HVACMode, str] = {v: k for k, v in CIELO_TO_HA_HVAC.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: CieloHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Cielo climate platform."""
coordinator = entry.runtime_data
devices = coordinator.data.parsed
async_add_entities([CieloClimate(coordinator, dev_id) for dev_id in devices])
def async_handle_api_call(
function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]:
"""Decorate api calls to handle exceptions and update state."""
async def wrap_api_call(*args: Any, **kwargs: Any) -> None:
"""Wrap services for api calls."""
entity: _T = args[0]
res: Any = None
try:
async with asyncio.timeout(TIMEOUT):
res = await function(*args, **kwargs)
except AuthenticationError as err:
raise ConfigEntryAuthFailed from err
except CIELO_ERRORS as err:
if isinstance(err, TimeoutError):
raise HomeAssistantError("API call timed out") from err
raise HomeAssistantError("Unable to perform API call") from err
LOGGER.debug(
"API call result for entity %s: type=%s keys=%s",
entity.entity_id,
type(res),
list(res.keys()) if isinstance(res, dict) else None,
)
if not isinstance(res, dict):
LOGGER.error(
"API function did not return a dictionary for entity %s, got %s",
entity.entity_id,
type(res),
)
raise HomeAssistantError("Invalid API response format")
data: dict[str, Any] | None = res.get("data")
if not data:
raise HomeAssistantError("API response contained no data payload")
await entity.coordinator.async_apply_action_result(entity.device_id, data)
return wrap_api_call
class CieloClimate(CieloDeviceEntity, ClimateEntity):
"""Representation of a Cielo Smart AC Controller."""
_attr_name = None
_attr_translation_key = "climate_device"
def __init__(self, coordinator: CieloDataUpdateCoordinator, device_id: str) -> None:
"""Initialize the climate device."""
super().__init__(coordinator, device_id)
self._attr_unique_id = device_id
@property
def temperature_unit(self) -> str:
"""Return the unit of temperature in Home Assistant format.
It can change over time based on the device settings, so we fetch it dynamically from the client.
"""
unit = self.client.temperature_unit()
if not unit:
return UnitOfTemperature.CELSIUS
normalized = unit.strip().lower()
if normalized in {"c", "°c", "celsius"}:
return UnitOfTemperature.CELSIUS
if normalized in {"f", "°f", "fahrenheit"}:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return dynamic feature flags based on the current mode."""
flags = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
if self.hvac_mode == HVACMode.HEAT_COOL:
flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
elif self.client.mode_supports_temperature():
flags |= ClimateEntityFeature.TARGET_TEMPERATURE
caps = self.client.mode_caps()
if caps.get("fan_levels"):
flags |= ClimateEntityFeature.FAN_MODE
if caps.get("swing"):
flags |= ClimateEntityFeature.SWING_MODE
if self.device_data and self.device_data.preset_modes:
flags |= ClimateEntityFeature.PRESET_MODE
return flags
@property
def current_humidity(self) -> int | None:
"""Return the current humidity, if available."""
if self.device_data:
return self.device_data.humidity
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the low target temperature for HEAT_COOL mode."""
return self.client.target_temperature_low(self.temperature_unit)
@property
def target_temperature_high(self) -> float | None:
"""Return the high target temperature for HEAT_COOL mode."""
return self.client.target_temperature_high(self.temperature_unit)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
mode = self.client.hvac_mode()
return CIELO_TO_HA_HVAC.get(mode, mode)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
modes = self.client.hvac_modes() or []
return [CIELO_TO_HA_HVAC.get(m, m) for m in modes]
@property
def current_temperature(self) -> float | None:
"""Return the current indoor temperature."""
return self.client.current_temperature()
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self.client.target_temperature()
@property
def min_temp(self) -> float:
"""Return the minimum possible target temperature."""
return self.client.min_temp()
@property
def max_temp(self) -> float:
"""Return the maximum possible target temperature."""
return self.client.max_temp()
@property
def target_temperature_step(self) -> float | None:
"""Return the precision of the thermostat."""
return self.client.target_temperature_step(self.temperature_unit)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return self.client.fan_mode()
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Fan modes are normalized in the backend to snake_case values that
match Home Assistant expectations (e.g. "low", "medium", "high", "auto").
This allows HA to translate and display icons correctly using the
integration strings definitions.
"""
return self.client.fan_modes()
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Swing modes are normalized in the backend to snake_case values
compatible with Home Assistant (e.g. "auto", "swing").
These values align with the integration translations so HA can display
proper labels and icons.
"""
return self.client.swing_modes()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self.client.preset_mode()
@property
def preset_modes(self) -> list[str] | None:
"""Return the list of available preset modes.
Preset modes are normalized in the backend to snake_case values that
match Home Assistant expectations (e.g. "home", "away", "sleep", "pets").
This allows HA to translate and display icons correctly using the
integration strings definitions.
"""
return self.client.preset_modes()
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
return self.device_data.swing_mode if self.device_data else None
@property
def precision(self) -> float:
"""Return the precision of the thermostat."""
return self.client.precision(self.temperature_unit)
@async_handle_api_call
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return await self.client.async_set_temperature(
self.temperature_unit,
**{
ATTR_TARGET_TEMP_LOW: kwargs.get(ATTR_TARGET_TEMP_LOW),
ATTR_TARGET_TEMP_HIGH: kwargs.get(ATTR_TARGET_TEMP_HIGH),
},
)
return await self.client.async_set_temperature(
self.temperature_unit,
**{ATTR_TEMPERATURE: kwargs.get(ATTR_TEMPERATURE)},
)
@async_handle_api_call
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
return await self.client.async_set_fan_mode(fan_mode)
@async_handle_api_call
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
return await self.client.async_set_preset_mode(preset_mode)
@async_handle_api_call
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
cielo_mode = HA_TO_CIELO_HVAC.get(hvac_mode)
return await self.client.async_set_hvac_mode(cielo_mode)
@async_handle_api_call
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode."""
return await self.client.async_set_swing_mode(swing_mode)
async def async_turn_on(self) -> None:
"""Turn the climate device on."""
modes = self.hvac_modes or []
# Select the first supported non-off mode when turning on
for mode in modes:
if mode != HVACMode.OFF:
await self.async_set_hvac_mode(mode)
return
raise HomeAssistantError("No non-off HVAC modes available to turn on device")
async def async_turn_off(self) -> None:
"""Turn the climate device off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -0,0 +1,99 @@
"""Config Flow for Cielo integration."""
from typing import Any, Final
from aiohttp import ClientError
from cieloconnectapi import CieloClient
from cieloconnectapi.exceptions import AuthenticationError, CieloError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_NAME, DOMAIN, LOGGER, TIMEOUT
DATA_SCHEMA: Final = vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class CieloConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cielo integration."""
VERSION = 1
MINOR_VERSION = 1
async def _async_validate_api_key(
self, api_key: str
) -> tuple[str | None, dict[str, str]]:
"""Validate the API key, initialize the client, and return errors or token."""
client = CieloClient(
api_key=api_key,
timeout=TIMEOUT,
session=async_get_clientsession(self.hass),
)
try:
token = await client.get_or_refresh_token()
devices = await client.get_devices_data()
if not devices.parsed:
return None, {"base": "no_devices"}
except AuthenticationError:
return None, {"base": "invalid_auth"}
except ConnectionError, TimeoutError, ClientError, CieloError:
return None, {"base": "cannot_connect"}
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during config flow validation")
return None, {"base": "unknown"}
return client.user_id, {CONF_TOKEN: token}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input:
api_key = user_input[CONF_API_KEY].strip()
user_id, validation_result = await self._async_validate_api_key(api_key)
if "base" in validation_result:
errors = validation_result
else:
token: str = validation_result[CONF_TOKEN]
user_input[CONF_API_KEY] = api_key
user_input[CONF_TOKEN] = token
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=DEFAULT_NAME,
data=user_input,
)
# Show the user form
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
"url": "https://www.home-assistant.io/integrations/cielo_home"
},
)
@@ -0,0 +1,24 @@
"""Constants for the Cielo Home integration."""
import logging
from typing import Final
from aiohttp import ClientError
from cieloconnectapi.exceptions import CieloError
from homeassistant.const import Platform
DOMAIN: Final = "cielo_home"
PLATFORMS: Final[list[Platform]] = [
Platform.CLIMATE,
]
DEFAULT_NAME: Final = "Cielo Home"
DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60
TIMEOUT: Final[int] = 20
LOGGER: Final = logging.getLogger(__package__)
CIELO_ERRORS: Final[tuple] = (
ClientError,
TimeoutError,
CieloError,
)
@@ -0,0 +1,107 @@
"""Coordinator for Cielo integration."""
from copy import copy
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, Final
from aiohttp import ClientError
from cieloconnectapi import CieloClient
from cieloconnectapi.exceptions import AuthenticationError, CieloError
from cieloconnectapi.model import CieloDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
REQUEST_REFRESH_DELAY: Final[int] = 2 * 60
@dataclass(slots=True)
class CieloData:
"""Data structure for the coordinator."""
raw: dict[str, Any]
parsed: dict[str, CieloDevice]
class CieloDataUpdateCoordinator(DataUpdateCoordinator[CieloData]):
"""Cielo Data Update Coordinator."""
config_entry: CieloHomeConfigEntry
def __init__(self, hass: HomeAssistant, entry: CieloHomeConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = CieloClient(
api_key=entry.data[CONF_API_KEY],
timeout=TIMEOUT,
token=entry.data[CONF_TOKEN],
session=async_get_clientsession(hass),
)
super().__init__(
hass,
LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
# The debouncer prevents multiple rapid refresh requests from triggering repeated full data fetches from the backend.
request_refresh_debouncer=Debouncer(
hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
async def _async_update_data(self) -> CieloData:
"""Fetch data from the API."""
try:
data = await self.client.get_devices_data()
except AuthenticationError as err:
raise ConfigEntryAuthFailed from err
except (TimeoutError, ConnectionError, CieloError, ClientError) as err:
raise UpdateFailed(err) from err
return CieloData(raw=data.raw, parsed=data.parsed)
async def async_apply_action_result(
self, device_id: str, data: dict[str, Any]
) -> None:
"""Apply an optimistic update from an API action response.
This updates the affected device locally in the coordinator state so the
UI reflects the change immediately without requiring a full backend refresh.
Performing a coordinator refresh after every action would fetch all devices
for the account, even when only a single device was updated. This is not
optimal from an API usage/cost perspective.
Instead, the coordinator applies the action result locally for the affected
device and schedules a later refresh to reconcile with the backend state.
"""
if not self.data or not self.data.parsed or device_id not in self.data.parsed:
await self.async_request_refresh()
return
new_parsed = dict(self.data.parsed)
dev = copy(new_parsed[device_id])
try:
dev.apply_update(data)
except KeyError, ValueError, TypeError:
await self.async_request_refresh()
return
new_parsed[device_id] = dev
self.async_set_updated_data(CieloData(raw=self.data.raw, parsed=new_parsed))
# Request a debounced refresh to reconcile with the backend state.
await self.async_request_refresh()
# Define the ConfigEntry type here to avoid circular imports
type CieloHomeConfigEntry = ConfigEntry[CieloDataUpdateCoordinator]
@@ -0,0 +1,76 @@
"""Base entity for Cielo integration."""
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import CieloDataUpdateCoordinator
class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]):
"""Representation of a Cielo base entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CieloDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize the Cielo base entity."""
super().__init__(coordinator)
self._device_id = device_id
self.client = CieloDeviceAPI(
coordinator.client, coordinator.data.parsed[device_id]
)
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (dev := self.device_data) is not None:
self.client.device_data = dev
super()._handle_coordinator_update()
@property
def device_data(self) -> CieloDevice | None:
"""Return the device data from the coordinator."""
return self.coordinator.data.parsed.get(self._device_id)
@property
def available(self) -> bool:
"""Return if the device is available and online."""
if not (super().available and self._device_id in self.coordinator.data.parsed):
return False
dev = self.device_data
return bool(dev and dev.device_status)
class CieloDeviceEntity(CieloBaseEntity):
"""Representation of a Cielo Device."""
def __init__(
self,
coordinator: CieloDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize the device entity."""
super().__init__(coordinator, device_id)
self.device_id = device_id
device = coordinator.data.parsed[device_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
manufacturer="Cielo",
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
)
@@ -0,0 +1,12 @@
{
"domain": "cielo_home",
"name": "Cielo Home",
"codeowners": ["@ihsan-cielo", "@mudasar-cielo"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cielo_home",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["cieloconnectapi"],
"quality_scale": "bronze",
"requirements": ["cielo-connect-api==1.0.6"]
}
@@ -0,0 +1,60 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,69 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "Invalid or expired API key; generate a new one",
"no_devices": "No devices found; make sure devices are set up in the Cielo Home app",
"no_user_id": "No valid user information found for the API key",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key from your Cielo Home account"
},
"description": "Sign in with your Cielo Home API key. Follow the [documentation]({url}) to learn how to get your API key.",
"title": "Connect to Cielo Home"
}
}
},
"entity": {
"climate": {
"climate_device": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"quiet": "Quiet",
"super_high": "Super high",
"ultra_high": "Ultra high"
}
},
"swing_mode": {
"state": {
"adjust": "Adjust",
"auto": "[%key:common::state::auto%]",
"auto_stop": "Auto Stop",
"pos1": "Position 1",
"pos10": "Position 10",
"pos11": "Position 11",
"pos12": "Position 12",
"pos13": "Position 13",
"pos14": "Position 14",
"pos15": "Position 15",
"pos2": "Position 2",
"pos3": "Position 3",
"pos4": "Position 4",
"pos5": "Position 5",
"pos6": "Position 6",
"pos7": "Position 7",
"pos8": "Position 8",
"pos9": "Position 9",
"swing": "Swing"
}
}
}
}
}
}
}
+6 -6
View File
@@ -8,10 +8,10 @@ import voluptuous as vol
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
repairs_flow_manager,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
from .const import DATA_CLOUD, DOMAIN
@@ -50,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
wait_task: asyncio.Task | None = None
_data: SubscriptionInfo | None = None
async def async_step_init(self, _: None = None) -> FlowResult:
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_change_plan()
async def async_step_confirm_change_plan(
self,
user_input: dict[str, str] | None = None,
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
return await self.async_step_change_plan()
@@ -66,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
step_id="confirm_change_plan", data_schema=vol.Schema({})
)
async def async_step_change_plan(self, _: None = None) -> FlowResult:
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
"""Wait for the user to authorize the app installation."""
cloud = self.hass.data[DATA_CLOUD]
@@ -107,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
return self.async_external_step_done(next_step_id="complete")
async def async_step_complete(self, _: None = None) -> FlowResult:
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
"""Handle the final step of a fix flow."""
return self.async_create_entry(data={})
async def async_step_timeout(self, _: None = None) -> FlowResult:
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
"""Handle the final step of a fix flow."""
return self.async_abort(reason="operation_took_too_long")
@@ -0,0 +1,48 @@
"""The Data Grand Lyon integration."""
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> bool:
"""Set up Data Grand Lyon from a config entry."""
session = async_get_clientsession(hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
coordinator = DataGrandLyonCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> None:
"""Handle config entry update (e.g., subentry changes)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,197 @@
"""Config flow for the Data Grand Lyon integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclPassageType
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, SUBENTRY_TYPE_STOP
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_RECONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
STEP_STOP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LINE): str,
vol.Required(CONF_STOP_ID): vol.Coerce(int),
}
)
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Data Grand Lyon."""
VERSION = 1
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentry types supported by this integration."""
return {
SUBENTRY_TYPE_STOP: StopSubentryFlowHandler,
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
if error := await self._test_connection(user_input):
errors["base"] = error
else:
return self.async_create_entry(title="Data Grand Lyon", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication with new credentials."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
if error := await self._test_connection(user_input):
errors["base"] = error
else:
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
),
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of credentials."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
creds = {
CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME),
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
if error := await self._test_connection(creds):
errors["base"] = error
else:
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=user_input
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_SCHEMA,
user_input or reconfigure_entry.data,
),
errors=errors,
)
async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
"""Test connectivity by making a dummy API call.
Returns None on success, or an error key for the errors dict.
"""
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
# the upstream library filters in memory so these placeholder values
# won't trigger an exception ; the returned list will be empty
await client.get_tcl_passages(
ligne="__test__", stop_id=0, passage_type=TclPassageType.ESTIMATED
)
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error testing Data Grand Lyon connection")
return "unknown"
return None
class StopSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the user step to add a new stop."""
entry = self._get_entry()
if user_input is not None:
line = user_input[CONF_LINE]
stop_id = user_input[CONF_STOP_ID]
unique_id = f"{line}_{stop_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
name = f"{line} - Stop {stop_id}"
return self.async_create_entry(
title=name,
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_STOP_DATA_SCHEMA,
)
@@ -0,0 +1,11 @@
"""Constants for the Data Grand Lyon integration."""
import logging
DOMAIN = "data_grand_lyon"
LOGGER = logging.getLogger(__package__)
SUBENTRY_TYPE_STOP = "stop"
CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
@@ -0,0 +1,83 @@
"""DataUpdateCoordinator for the Data Grand Lyon integration."""
import asyncio
from datetime import timedelta
from aiohttp import ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclPassage
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_STOP
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
class DataGrandLyonCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
"""Coordinator for the Data Grand Lyon integration."""
config_entry: DataGrandLyonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
client: DataGrandLyonClient,
) -> None:
"""Initialize the coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
"""Fetch data for all monitored stops."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
stop_tasks = [
self.client.get_tcl_passages(
ligne=subentry.data[CONF_LINE],
stop_id=subentry.data[CONF_STOP_ID],
)
for subentry in stop_subentries
]
stop_results: list[list[TclPassage] | BaseException] = await asyncio.gather(
*stop_tasks, return_exceptions=True
)
stops: dict[str, list[TclPassage]] = {}
for i, subentry in enumerate(stop_subentries):
result = stop_results[i]
if isinstance(result, BaseException):
if isinstance(result, ClientResponseError) and result.status in (
401,
403,
):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from result
LOGGER.warning(
"Error fetching departures for stop %s: %s",
subentry.subentry_id,
result,
)
continue
stops[subentry.subentry_id] = result
if stop_subentries and not stops:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_all_stops",
)
return stops
@@ -0,0 +1,27 @@
"""Diagnostics support for the Data Grand Lyon integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .coordinator import DataGrandLyonConfigEntry
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
subentry_id: [asdict(passage) for passage in passages]
for subentry_id, passages in coordinator.data.items()
},
}
@@ -0,0 +1,42 @@
{
"entity": {
"sensor": {
"next_departure_1": {
"default": "mdi:bus-clock"
},
"next_departure_1_direction": {
"default": "mdi:directions"
},
"next_departure_1_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
},
"next_departure_2": {
"default": "mdi:bus-clock"
},
"next_departure_2_direction": {
"default": "mdi:directions"
},
"next_departure_2_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
},
"next_departure_3": {
"default": "mdi:bus-clock"
},
"next_departure_3_direction": {
"default": "mdi:directions"
},
"next_departure_3_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "data_grand_lyon",
"name": "Data Grand Lyon",
"codeowners": ["@Crocmagnon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["data-grand-lyon-ha==0.5.0"]
}
@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities use the coordinator pattern and do not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: This is a service integration; there are no discoverable devices.
discovery:
status: exempt
comment: This is a service integration; there are no discoverable devices.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -0,0 +1,180 @@
"""Sensor platform for the Data Grand Lyon integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from data_grand_lyon_ha import TclPassage, TclPassageType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SUBENTRY_TYPE_STOP
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
PARALLEL_UPDATES = 0
_TZ_PARIS = ZoneInfo("Europe/Paris")
_DEPARTURE_TYPE_OPTIONS = [t.name.lower() for t in TclPassageType]
def _departure_time(departure: TclPassage) -> datetime:
"""Return the departure time, localized to Europe/Paris if naive."""
dt = departure.heure_passage
if dt.tzinfo is None:
return dt.replace(tzinfo=_TZ_PARIS)
return dt
@dataclass(frozen=True, kw_only=True)
class DataGrandLyonStopSensorEntityDescription(SensorEntityDescription):
"""Describes a Data Grand Lyon stop departure sensor entity."""
departure_index: int
value_fn: Callable[[TclPassage], StateType | datetime]
STOP_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonStopSensorEntityDescription, ...] = (
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1",
translation_key="next_departure_1",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=0,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1_direction",
translation_key="next_departure_1_direction",
departure_index=0,
value_fn=lambda p: p.direction,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1_type",
translation_key="next_departure_1_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=0,
value_fn=lambda p: p.type.name.lower(),
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2",
translation_key="next_departure_2",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=1,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2_direction",
translation_key="next_departure_2_direction",
departure_index=1,
value_fn=lambda p: p.direction,
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2_type",
translation_key="next_departure_2_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=1,
value_fn=lambda p: p.type.name.lower(),
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3",
translation_key="next_departure_3",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=2,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3_direction",
translation_key="next_departure_3_direction",
departure_index=2,
value_fn=lambda p: p.direction,
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3_type",
translation_key="next_departure_3_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=2,
value_fn=lambda p: p.type.name.lower(),
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
coordinator = entry.runtime_data
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
DataGrandLyonStopSensor(coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
class DataGrandLyonStopSensor(
CoordinatorEntity[DataGrandLyonCoordinator], SensorEntity
):
"""Sensor for Data Grand Lyon stop departures."""
_attr_has_entity_name = True
entity_description: DataGrandLyonStopSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonStopSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._subentry_id = subentry.subentry_id
assert subentry.unique_id is not None
self._attr_unique_id = f"{subentry.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.unique_id)},
name=subentry.title,
manufacturer="TCL",
model="Stop",
entry_type=DeviceEntryType.SERVICE,
)
def _get_departure(self) -> TclPassage | None:
"""Return the departure for this sensor's index, or None."""
departures = self.coordinator.data.get(self._subentry_id, [])
index = self.entity_description.departure_index
if index >= len(departures):
return None
return departures[index]
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
departure = self._get_departure()
if departure is None:
return None
return self.entity_description.value_fn(departure)
@@ -0,0 +1,116 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your password on data.grandlyon.com.",
"username": "Your username on data.grandlyon.com."
}
}
}
},
"config_subentries": {
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"entry_type": "Transit stop",
"initiate_flow": {
"user": "Add transit stop"
},
"step": {
"user": {
"data": {
"line": "Line",
"stop_id": "Stop ID"
}
}
}
}
},
"entity": {
"sensor": {
"next_departure_1": {
"name": "Next departure 1"
},
"next_departure_1_direction": {
"name": "Next departure 1 direction"
},
"next_departure_1_type": {
"name": "Next departure 1 type",
"state": {
"estimated": "Estimated",
"theoretical": "Theoretical"
}
},
"next_departure_2": {
"name": "Next departure 2"
},
"next_departure_2_direction": {
"name": "Next departure 2 direction"
},
"next_departure_2_type": {
"name": "Next departure 2 type",
"state": {
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
}
},
"next_departure_3": {
"name": "Next departure 3"
},
"next_departure_3_direction": {
"name": "Next departure 3 direction"
},
"next_departure_3_type": {
"name": "Next departure 3 type",
"state": {
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
}
}
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
"update_failed_all_stops": {
"message": "Error fetching Data Grand Lyon data: all requests failed."
}
}
}
@@ -1,4 +1,4 @@
"""The Denon RS232 integration."""
"""The Denon RS-232 integration."""
from denon_rs232 import DenonReceiver, ReceiverState
from denon_rs232.models import MODELS
@@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
"""Set up Denon RS232 from a config entry."""
"""Set up Denon RS-232 from a config entry."""
port = entry.data[CONF_DEVICE]
model = MODELS[entry.data[CONF_MODEL]]
receiver = DenonReceiver(port, model=model)
@@ -1,4 +1,4 @@
"""Config flow for the Denon RS232 integration."""
"""Config flow for the Denon RS-232 integration."""
from typing import Any
@@ -63,7 +63,7 @@ async def _async_attempt_connect(port: str, model_key: str) -> str | None:
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Denon RS232."""
"""Handle a config flow for Denon RS-232."""
VERSION = 1
@@ -1,4 +1,4 @@
"""Constants for the Denon RS232 integration."""
"""Constants for the Denon RS-232 integration."""
import logging
@@ -1,6 +1,6 @@
{
"domain": "denon_rs232",
"name": "Denon RS232",
"name": "Denon RS-232",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
@@ -1,4 +1,4 @@
"""Media player platform for the Denon RS232 integration."""
"""Media player platform for the Denon RS-232 integration."""
from typing import Literal, cast
@@ -77,7 +77,7 @@ async def async_setup_entry(
config_entry: DenonRS232ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Denon RS232 media player."""
"""Set up the Denon RS-232 media player."""
receiver = config_entry.runtime_data
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
@@ -94,7 +94,7 @@ async def async_setup_entry(
class DenonRS232MediaPlayer(MediaPlayerEntity):
"""Representation of a Denon receiver controlled over RS232."""
"""Representation of a Denon receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
+20 -19
View File
@@ -16,9 +16,11 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADVANCED_OPTIONS,
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
@@ -37,15 +39,17 @@ from .const import (
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
}
)
DATA_SCHEMA_ADV = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
vol.Required(CONF_ADVANCED_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
}
),
SectionConfig(collapsed=True),
),
}
)
@@ -111,10 +115,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
hostname = user_input[CONF_HOSTNAME]
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6)
port = user_input.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT)
advanced_options = user_input[CONF_ADVANCED_OPTIONS]
resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = advanced_options.get(
CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6
)
port = advanced_options.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = advanced_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
validate = await async_validate_hostname(
hostname, resolver, resolver_ipv6, port, port_ipv6
@@ -149,12 +156,6 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
if self.show_advanced_options is True:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA_ADV,
errors=errors,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
+1
View File
@@ -12,6 +12,7 @@ CONF_PORT_IPV6 = "port_ipv6"
CONF_IPV4 = "ipv4"
CONF_IPV6 = "ipv6"
CONF_IPV6_V4 = "ipv6_v4"
CONF_ADVANCED_OPTIONS = "advanced_options"
DEFAULT_HOSTNAME = "myip.opendns.com"
DEFAULT_IPV6 = False
+30 -19
View File
@@ -9,18 +9,28 @@
"step": {
"user": {
"data": {
"hostname": "Hostname",
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
"resolver": "IPv4 resolver",
"resolver_ipv6": "IPv6 resolver"
"hostname": "Hostname"
},
"data_description": {
"hostname": "The hostname for which to perform the DNS query.",
"port": "Port used for the IPv4 lookup.",
"port_ipv6": "Port used for the IPv6 lookup.",
"resolver": "Resolver used for the IPv4 lookup.",
"resolver_ipv6": "Resolver used for the IPv6 lookup."
"hostname": "The hostname for which to perform the DNS query."
},
"sections": {
"advanced_options": {
"data": {
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
"resolver": "IPv4 resolver",
"resolver_ipv6": "IPv6 resolver"
},
"data_description": {
"port": "Port used for the IPv4 lookup.",
"port_ipv6": "Port used for the IPv6 lookup.",
"resolver": "Resolver used for the IPv4 lookup.",
"resolver_ipv6": "Resolver used for the IPv6 lookup."
},
"description": "Optionally change resolvers and ports.",
"name": "Advanced options"
}
}
}
}
@@ -53,17 +63,18 @@
"step": {
"init": {
"data": {
"port": "[%key:component::dnsip::config::step::user::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]"
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver_ipv6%]"
},
"data_description": {
"port": "[%key:component::dnsip::config::step::user::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]"
}
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver_ipv6%]"
},
"description": "Optionally change resolvers and ports."
}
}
}
+3 -4
View File
@@ -2,8 +2,7 @@
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -17,13 +16,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
self.hass.config_entries.async_schedule_reload(self.entry_id)
+15 -3
View File
@@ -1,22 +1,34 @@
"""The Duco integration."""
from duco import DucoClient, build_ssl_context
import re
from duco_connectivity import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
ssl_context = await hass.async_add_executor_job(build_ssl_context)
# Remove entity registry entries for the temperature and box_temperature
# sensors that were removed when migrating to python-duco-connectivity.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
entity_registry.async_remove(entity_entry.entity_id)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+2 -4
View File
@@ -3,8 +3,8 @@
import logging
from typing import Any
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -158,11 +158,9 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+3 -3
View File
@@ -3,9 +3,9 @@
from dataclasses import dataclass
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+2 -2
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
write_remaining = await coordinator.client.async_get_write_requests_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
+1 -1
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco.models import Node
from duco_connectivity.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+2 -2
View File
@@ -2,8 +2,8 @@
import logging
from duco.exceptions import DucoError, DucoRateLimitError
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
+2 -2
View File
@@ -11,9 +11,9 @@
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.4.1"],
"requirements": ["python-duco-connectivity==0.2.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
+9 -23
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from datetime import datetime
import logging
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -18,7 +18,6 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
@@ -54,20 +53,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[s.lower() for s in VentilationState],
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
value_fn=lambda node: (
node.ventilation.state.lower() if node.ventilation else None
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="target_flow_level",
translation_key="target_flow_level",
@@ -92,17 +89,6 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
+5 -3
View File
@@ -47,9 +47,6 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
@@ -102,5 +99,10 @@
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
},
"system_health": {
"info": {
"write_requests_remaining": "Remaining write requests today"
}
}
}
@@ -0,0 +1,47 @@
"""Provide info to system health."""
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .coordinator import DucoConfigEntry
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def _async_get_write_requests_remaining(
config_entry: DucoConfigEntry,
) -> int | dict[str, str]:
"""Get the remaining write-request quota for system health."""
try:
return (
await config_entry.runtime_data.client.async_get_write_requests_remaining()
)
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not config_entries:
return {}
return {
"write_requests_remaining": _async_get_write_requests_remaining(
config_entries[0]
)
}
@@ -2,7 +2,11 @@
from typing import Any
from easyenergy import EasyEnergy, EasyEnergyConnectionError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -16,14 +20,22 @@ class EasyEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user")
if user_input is not None:
easyenergy = EasyEnergy(session=async_get_clientsession(self.hass))
today = dt_util.now().date()
try:
await easyenergy.energy_prices(start_date=today, end_date=today)
except EasyEnergyConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="easyEnergy",
data={},
)
return self.async_create_entry(
title="easyEnergy",
data={},
)
return self.async_show_form(step_id="user", errors=errors)
@@ -76,7 +76,10 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
)
except EasyEnergyConnectionError as err:
raise UpdateFailed("Error communicating with easyEnergy API") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
return EasyEnergyData(
energy_today=energy_today,
@@ -1,12 +1,33 @@
{
"entity": {
"sensor": {
"average_price": {
"default": "mdi:cash-multiple"
},
"current_hour_price": {
"default": "mdi:cash"
},
"highest_price_time": {
"default": "mdi:clock-outline"
},
"hours_priced_equal_or_higher": {
"default": "mdi:clock"
},
"hours_priced_equal_or_lower": {
"default": "mdi:clock"
},
"lowest_price_time": {
"default": "mdi:clock-outline"
},
"max_price": {
"default": "mdi:cash-plus"
},
"min_price": {
"default": "mdi:cash-minus"
},
"next_hour_price": {
"default": "mdi:cash"
},
"percentage_of_max": {
"default": "mdi:percent"
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==3.0.0"],
"requirements": ["easyenergy==3.0.1"],
"single_config_entry": true
}
@@ -31,6 +31,9 @@ from .coordinator import (
EasyEnergyDataUpdateCoordinator,
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EasyEnergySensorEntityDescription(SensorEntityDescription):
@@ -3,6 +3,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
@@ -44,6 +47,9 @@
}
},
"exceptions": {
"connection_error": {
"message": "Error communicating with the easyEnergy API."
},
"invalid_date": {
"message": "Invalid date provided. Got {date}"
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
}
@@ -72,9 +72,11 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
+77
View File
@@ -0,0 +1,77 @@
"""Support for ElkM1 number entities."""
import logging
from typing import Any, cast
from elkm1_lib.const import SettingFormat
from elkm1_lib.elements import Element
from elkm1_lib.settings import Setting
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .models import ELKM1Data
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk-M1 number platform."""
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
number_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format in (SettingFormat.NUMBER, SettingFormat.TIMER)
]
create_elk_entities(
elk_data,
number_settings,
"setting",
ElkNumberSetting,
entities,
)
async_add_entities(entities)
class ElkNumberSetting(ElkAttachedEntity, NumberEntity):
"""Representation of an Elk-M1 Number Setting."""
_element: Setting
_attr_native_min_value = 0
_attr_native_max_value = 65535
_attr_native_step = 1
def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None:
"""Initialize the number setting."""
super().__init__(element, elk, elk_data)
if element.value_format == SettingFormat.TIMER:
self._attr_device_class = NumberDeviceClass.DURATION
self._attr_native_unit_of_measurement = UnitOfTime.SECONDS
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Guard against the panel possibly changing the underlying
# type without us knowing about the change
if isinstance(self._element.value, int):
self._attr_native_value = self._element.value
else:
self._attr_available = False
_LOGGER.warning(
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
self.entity_id,
)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the setting."""
self._element.set(int(value))
+66
View File
@@ -0,0 +1,66 @@
"""Support for ElkM1 time entities."""
from datetime import time as dt_time
import logging
from typing import Any, cast
from elkm1_lib.const import SettingFormat
from elkm1_lib.elements import Element
from elkm1_lib.settings import Setting
from homeassistant.components.time import TimeEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk-M1 time platform."""
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
time_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format == SettingFormat.TIME_OF_DAY
]
create_elk_entities(
elk_data,
time_settings,
"setting",
ElkTimeSetting,
entities,
)
async_add_entities(entities)
class ElkTimeSetting(ElkAttachedEntity, TimeEntity):
"""Representation of an Elk-M1 Time Setting."""
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
value = self._element.value
# Guard against the panel possibly changing the underlying
# type without us knowing about the change
if isinstance(value, tuple):
self._attr_native_value = dt_time(hour=value[0], minute=value[1])
else:
self._attr_available = False
_LOGGER.warning(
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
self.entity_id,
)
async def async_set_value(self, value: dt_time) -> None:
"""Set the time of the setting."""
self._element.set((value.hour, value.minute))
+4 -5
View File
@@ -4,8 +4,7 @@ from typing import cast
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.core import HomeAssistant
from .manager import async_replace_device
@@ -43,7 +42,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -52,7 +51,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_migrate(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the migrate step of a fix flow."""
if user_input is None:
return self.async_show_form(
@@ -66,7 +65,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_manual(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the manual step of a fix flow."""
if user_input is None:
return self.async_show_form(
@@ -8,6 +8,7 @@ from functools import partial
import logging
import re
from typing import Any, TypedDict, cast
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
@@ -24,7 +25,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -226,7 +227,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
self.fritz_status = FritzStatus(fc=self.connection)
self.fritz_call = FritzCall(fc=self.connection)
info = self.fritz_status.get_device_info()
try:
info = self.fritz_status.get_device_info()
except ParseError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_parse_device_info",
) from ex
_LOGGER.debug(
"gathered device info of %s %s",
@@ -185,6 +185,9 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
+2 -2
View File
@@ -16,6 +16,8 @@ from .coordinator import FritzboxDataUpdateCoordinator
class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
"""Basis FritzBox entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FritzboxDataUpdateCoordinator,
@@ -27,11 +29,9 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
self.ain = ain
if entity_description is not None:
self._attr_has_entity_name = True
self.entity_description = entity_description
self._attr_unique_id = f"{ain}_{entity_description.key}"
else:
self._attr_name = self.data.name
self._attr_unique_id = ain
@property
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["afsapi"],
"requirements": ["afsapi==1.0.0"],
"requirements": ["afsapi==1.0.1"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
@@ -196,7 +196,9 @@ class AFSAPIDevice(MediaPlayerEntity):
if not self._attr_source_list:
self.__modes_by_label = {
(mode.label or mode.id): mode.key for mode in await afsapi.get_modes()
(mode.label or mode.id): mode.key
for mode in await afsapi.get_modes()
if mode.selectable
}
self._attr_source_list = list(self.__modes_by_label)
+7 -8
View File
@@ -8,10 +8,9 @@ from aiohasupervisor import SupervisorError
from aiohasupervisor.models import ContextType
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from . import get_addons_list
from .const import (
@@ -77,7 +76,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
return placeholders or None
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
"""Return form for suggestion."""
return self.async_show_form(
step_id=suggestion.key,
@@ -86,7 +85,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
last_step=True,
)
async def async_step_init(self, _: None = None) -> FlowResult:
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
# Out of sync with supervisor, issue is resolved or not fixable. Remove it
if not self.issue or not self.issue.suggestions:
@@ -108,7 +107,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
# Always show a form for one suggestion to explain to user what's happening
return self._async_form_for_suggestion(self.issue.suggestions[0])
async def async_step_fix_menu(self, _: None = None) -> FlowResult:
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
"""Show the fix menu."""
assert self.issue
@@ -120,7 +119,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
async def _async_step_apply_suggestion(
self, suggestion: Suggestion, confirmed: bool = False
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle applying a suggestion as a flow step. Optionally request confirmation."""
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
return self._async_form_for_suggestion(suggestion)
@@ -137,13 +136,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
suggestion: Suggestion,
) -> Callable[
[SupervisorIssueRepairFlow, dict[str, str] | None],
Coroutine[Any, Any, FlowResult],
Coroutine[Any, Any, RepairsFlowResult],
]:
"""Generate a step handler for a suggestion."""
async def _async_step(
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle a flow step for a suggestion."""
return await self._async_step_apply_suggestion(
suggestion, confirmed=user_input is not None
@@ -4,6 +4,7 @@ import logging
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
from pycec.const import (
CMD_STANDBY,
KEY_BACKWARD,
KEY_FORWARD,
KEY_MUTE_TOGGLE,
@@ -93,7 +94,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
async def async_turn_off(self) -> None:
"""Turn device off."""
self._device.turn_off()
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
self._attr_state = MediaPlayerState.OFF
self.async_write_ha_state()
+3 -2
View File
@@ -3,7 +3,8 @@
import logging
from typing import Any
from pycec.const import POWER_OFF, POWER_ON
from pycec.commands import CecCommand
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import HomeAssistant
@@ -50,7 +51,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn device off."""
self._device.turn_off()
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
self._attr_is_on = False
self.async_write_ha_state()
@@ -1,8 +1,11 @@
"""Repairs for Home Assistant."""
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN
@@ -18,7 +21,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -28,7 +31,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
entries = self.hass.config_entries.async_entries(self.domain)
for entry in entries:
@@ -37,7 +40,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_get(self.hass).async_ignore(
DOMAIN, f"integration_not_found.{self.domain}", True
@@ -58,7 +61,7 @@ class OrphanedConfigEntryFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -68,14 +71,14 @@ class OrphanedConfigEntryFlow(RepairsFlow):
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
await self.hass.config_entries.async_remove(self.entry_id)
return self.async_create_entry(data={})
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_get(self.hass).async_ignore(
DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True
@@ -0,0 +1,13 @@
"""Provides diagnostics for the Home Assistant Connect ZBT-2 integration."""
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {"config_entry": config_entry.as_dict()}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.10.0"]
"requirements": ["homematicip==2.11.0"]
}
@@ -1,11 +1,9 @@
"""Repairs for HomeWizard integration."""
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from .config_flow import async_request_token
@@ -19,14 +17,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
@@ -38,7 +36,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
async def async_step_authorize(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
) -> RepairsFlowResult:
"""Handle the authorize step of a fix flow."""
ip_address = self.entry.data[CONF_IP_ADDRESS]
@@ -4,8 +4,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from python_qube_heatpump.models import QubeState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -13,6 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from .coordinator import QubeData
from .entity import QubeEntity
PARALLEL_UPDATES = 0
@@ -29,7 +28,7 @@ if TYPE_CHECKING:
class QubeBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Binary sensor entity description for Qube Heat Pump."""
value_fn: Callable[[QubeState], bool | None]
value_fn: Callable[[QubeData], bool | None]
BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
@@ -37,58 +36,58 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
QubeBinarySensorEntityDescription(
key="source_pump",
translation_key="source_pump",
value_fn=lambda data: data.dout_srcpmp_val,
value_fn=lambda data: data.state.dout_srcpmp_val,
),
QubeBinarySensorEntityDescription(
key="user_pump",
translation_key="user_pump",
value_fn=lambda data: data.dout_usrpmp_val,
value_fn=lambda data: data.state.dout_usrpmp_val,
),
QubeBinarySensorEntityDescription(
key="four_way_valve",
translation_key="four_way_valve",
value_fn=lambda data: data.dout_fourwayvlv_val,
value_fn=lambda data: data.state.dout_fourwayvlv_val,
),
QubeBinarySensorEntityDescription(
key="cooling_output",
translation_key="cooling_output",
value_fn=lambda data: data.dout_cooling_val,
value_fn=lambda data: data.state.dout_cooling_val,
),
QubeBinarySensorEntityDescription(
key="three_way_valve",
translation_key="three_way_valve",
value_fn=lambda data: data.dout_threewayvlv_val,
value_fn=lambda data: data.state.dout_threewayvlv_val,
),
QubeBinarySensorEntityDescription(
key="buffer_pump",
translation_key="buffer_pump",
value_fn=lambda data: data.dout_bufferpmp_val,
value_fn=lambda data: data.state.dout_bufferpmp_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_1",
translation_key="heater_step_1",
value_fn=lambda data: data.dout_heaterstep1_val,
value_fn=lambda data: data.state.dout_heaterstep1_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_2",
translation_key="heater_step_2",
value_fn=lambda data: data.dout_heaterstep2_val,
value_fn=lambda data: data.state.dout_heaterstep2_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_3",
translation_key="heater_step_3",
value_fn=lambda data: data.dout_heaterstep3_val,
value_fn=lambda data: data.state.dout_heaterstep3_val,
),
# System status
QubeBinarySensorEntityDescription(
key="keypad",
translation_key="keypad",
value_fn=lambda data: data.keybonoff,
value_fn=lambda data: data.state.keybonoff,
),
QubeBinarySensorEntityDescription(
key="day_mode",
translation_key="day_mode",
value_fn=lambda data: data.daynightmode,
value_fn=lambda data: data.state.daynightmode,
),
# Alarms
QubeBinarySensorEntityDescription(
@@ -96,84 +95,84 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
translation_key="alarm_antilegionella_timeout",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_maxtime_antileg_active,
value_fn=lambda data: data.state.al_maxtime_antileg_active,
),
QubeBinarySensorEntityDescription(
key="alarm_dhw_timeout",
translation_key="alarm_dhw_timeout",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_maxtime_dhw_active,
value_fn=lambda data: data.state.al_maxtime_dhw_active,
),
QubeBinarySensorEntityDescription(
key="alarm_dewpoint",
translation_key="alarm_dewpoint",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_dewpoint_active,
value_fn=lambda data: data.state.al_dewpoint_active,
),
QubeBinarySensorEntityDescription(
key="alarm_supply_too_hot",
translation_key="alarm_supply_too_hot",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_underfloorsafety_active,
value_fn=lambda data: data.state.al_underfloorsafety_active,
),
QubeBinarySensorEntityDescription(
key="alarm_flow",
translation_key="alarm_flow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alrm_flw,
value_fn=lambda data: data.state.alrm_flw,
),
QubeBinarySensorEntityDescription(
key="alarm_central_heating",
translation_key="alarm_central_heating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.usralrms,
value_fn=lambda data: data.state.usralrms,
),
QubeBinarySensorEntityDescription(
key="alarm_cooling",
translation_key="alarm_cooling",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.coolingalrms,
value_fn=lambda data: data.state.coolingalrms,
),
QubeBinarySensorEntityDescription(
key="alarm_heating",
translation_key="alarm_heating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.heatingalrms,
value_fn=lambda data: data.state.heatingalrms,
),
QubeBinarySensorEntityDescription(
key="alarm_working_hours",
translation_key="alarm_working_hours",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alarmmng_al_workinghour,
value_fn=lambda data: data.state.alarmmng_al_workinghour,
),
QubeBinarySensorEntityDescription(
key="alarm_source",
translation_key="alarm_source",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.srsalrm,
value_fn=lambda data: data.state.srsalrm,
),
QubeBinarySensorEntityDescription(
key="alarm_global",
translation_key="alarm_global",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.glbal,
value_fn=lambda data: data.state.glbal,
),
QubeBinarySensorEntityDescription(
key="alarm_compressor",
translation_key="alarm_compressor",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alarmmng_al_pwrplus,
value_fn=lambda data: data.state.alarmmng_al_pwrplus,
),
# Sensor/controller status
QubeBinarySensorEntityDescription(
@@ -181,76 +180,76 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
translation_key="room_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.roomprb_en,
value_fn=lambda data: data.state.roomprb_en,
),
QubeBinarySensorEntityDescription(
key="plant_sensor_enabled",
translation_key="plant_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.plantprb_en,
value_fn=lambda data: data.state.plantprb_en,
),
QubeBinarySensorEntityDescription(
key="buffer_sensor_enabled",
translation_key="buffer_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.bufferprb_en,
value_fn=lambda data: data.state.bufferprb_en,
),
QubeBinarySensorEntityDescription(
key="dhw_controller_enabled",
translation_key="dhw_controller_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.en_dhwpid,
value_fn=lambda data: data.state.en_dhwpid,
),
# Demand signals
QubeBinarySensorEntityDescription(
key="plant_demand",
translation_key="plant_demand",
value_fn=lambda data: data.plantdemand,
value_fn=lambda data: data.state.plantdemand,
),
QubeBinarySensorEntityDescription(
key="external_demand",
translation_key="external_demand",
value_fn=lambda data: data.id_demand,
value_fn=lambda data: data.state.id_demand,
),
QubeBinarySensorEntityDescription(
key="thermostat_demand",
translation_key="thermostat_demand",
value_fn=lambda data: data.thermostatdemand,
value_fn=lambda data: data.state.thermostatdemand,
),
# Digital inputs
QubeBinarySensorEntityDescription(
key="summer_mode",
translation_key="summer_mode",
value_fn=lambda data: data.id_summerwinter,
value_fn=lambda data: data.state.id_summerwinter,
),
QubeBinarySensorEntityDescription(
key="dewpoint",
translation_key="dewpoint",
value_fn=lambda data: data.dewpoint,
value_fn=lambda data: data.state.dewpoint,
),
QubeBinarySensorEntityDescription(
key="booster_security",
translation_key="booster_security",
value_fn=lambda data: data.boostersecurity,
value_fn=lambda data: data.state.boostersecurity,
),
QubeBinarySensorEntityDescription(
key="source_flow",
translation_key="source_flow",
value_fn=lambda data: data.srcflw,
value_fn=lambda data: data.state.srcflw,
),
QubeBinarySensorEntityDescription(
key="anti_legionella",
translation_key="anti_legionella",
value_fn=lambda data: data.req_antileg_1,
value_fn=lambda data: data.state.req_antileg_1,
),
# Energy
QubeBinarySensorEntityDescription(
key="pv_surplus",
translation_key="pv_surplus",
value_fn=lambda data: data.surplus_pv,
value_fn=lambda data: data.state.surplus_pv,
),
)
@@ -3,7 +3,12 @@
from homeassistant.const import Platform
DOMAIN = "hr_energy_qube"
PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR)
PLATFORMS = (
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
)
DEFAULT_PORT = 502
DEFAULT_SCAN_INTERVAL = 15
@@ -1,5 +1,6 @@
"""DataUpdateCoordinator for Qube Heat Pump."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
@@ -18,7 +19,15 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
class QubeCoordinator(DataUpdateCoordinator[QubeState]):
@dataclass
class QubeData:
"""Data from the Qube coordinator."""
state: QubeState
switches: dict[str, bool | None]
class QubeCoordinator(DataUpdateCoordinator[QubeData]):
"""Qube Heat Pump data coordinator."""
def __init__(
@@ -34,16 +43,17 @@ class QubeCoordinator(DataUpdateCoordinator[QubeState]):
config_entry=entry,
)
async def _async_update_data(self) -> QubeState:
async def _async_update_data(self) -> QubeData:
"""Fetch data from the device."""
try:
data = await self.client.get_all_data()
state = await self.client.get_all_data()
switches = await self.client.read_all_switches()
except (ConnectionError, TimeoutError, OSError) as exc:
raise UpdateFailed(
f"Error communicating with Qube heat pump: {exc}"
) from exc
if data is None:
if state is None:
raise UpdateFailed("No data received from Qube heat pump")
return data
return QubeData(state=state, switches=switches)
@@ -4,8 +4,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from python_qube_heatpump.models import QubeState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -21,6 +19,7 @@ from homeassistant.const import (
)
from homeassistant.helpers.typing import StateType
from .coordinator import QubeData
from .entity import QubeEntity
PARALLEL_UPDATES = 0
@@ -52,12 +51,12 @@ STATUS_MAP: dict[int, str] = {
class QubeSensorEntityDescription(SensorEntityDescription):
"""Sensor entity description for Qube Heat Pump."""
value_fn: Callable[[QubeState], StateType]
value_fn: Callable[[QubeData], StateType]
def _status_value(data: QubeState) -> StateType:
def _status_value(data: QubeData) -> StateType:
"""Return status string from status code."""
code = data.status_code
code = data.state.status_code
if code is None:
return None
return STATUS_MAP.get(code)
@@ -71,7 +70,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_supply,
value_fn=lambda data: data.state.temp_supply,
),
QubeSensorEntityDescription(
key="temp_return",
@@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_return,
value_fn=lambda data: data.state.temp_return,
),
QubeSensorEntityDescription(
key="temp_source_in",
@@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_source_in,
value_fn=lambda data: data.state.temp_source_in,
),
QubeSensorEntityDescription(
key="temp_source_out",
@@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_source_out,
value_fn=lambda data: data.state.temp_source_out,
),
QubeSensorEntityDescription(
key="temp_room",
@@ -107,7 +106,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_room,
value_fn=lambda data: data.state.temp_room,
),
QubeSensorEntityDescription(
key="temp_dhw",
@@ -116,7 +115,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_dhw,
value_fn=lambda data: data.state.temp_dhw,
),
QubeSensorEntityDescription(
key="temp_outside",
@@ -125,7 +124,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_outside,
value_fn=lambda data: data.state.temp_outside,
),
QubeSensorEntityDescription(
key="power_thermic",
@@ -134,7 +133,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.power_thermic,
value_fn=lambda data: data.state.power_thermic,
),
QubeSensorEntityDescription(
key="power_electric",
@@ -143,7 +142,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.power_electric,
value_fn=lambda data: data.state.power_electric,
),
QubeSensorEntityDescription(
key="energy_total_electric",
@@ -152,7 +151,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_fn=lambda data: data.energy_total_electric,
value_fn=lambda data: data.state.energy_total_electric,
),
QubeSensorEntityDescription(
key="energy_total_thermic",
@@ -161,14 +160,14 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_fn=lambda data: data.energy_total_thermic,
value_fn=lambda data: data.state.energy_total_thermic,
),
QubeSensorEntityDescription(
key="cop_calc",
translation_key="cop_calc",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.cop_calc,
value_fn=lambda data: data.state.cop_calc,
),
QubeSensorEntityDescription(
key="compressor_speed",
@@ -176,7 +175,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.compressor_speed,
value_fn=lambda data: data.state.compressor_speed,
),
QubeSensorEntityDescription(
key="flow_rate",
@@ -185,7 +184,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.flow_rate,
value_fn=lambda data: data.state.flow_rate,
),
QubeSensorEntityDescription(
key="setpoint_room_heat_day",
@@ -194,7 +193,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_heat_day,
value_fn=lambda data: data.state.setpoint_room_heat_day,
),
QubeSensorEntityDescription(
key="setpoint_room_heat_night",
@@ -203,7 +202,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_heat_night,
value_fn=lambda data: data.state.setpoint_room_heat_night,
),
QubeSensorEntityDescription(
key="setpoint_room_cool_day",
@@ -212,7 +211,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_cool_day,
value_fn=lambda data: data.state.setpoint_room_cool_day,
),
QubeSensorEntityDescription(
key="setpoint_room_cool_night",
@@ -221,7 +220,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_cool_night,
value_fn=lambda data: data.state.setpoint_room_cool_night,
),
QubeSensorEntityDescription(
key="status_heatpump",
@@ -199,6 +199,33 @@
"temp_supply": {
"name": "Supply temperature CH"
}
},
"switch": {
"anti_legionella_cycle": {
"name": "Anti-legionella cycle"
},
"heating_curve": {
"name": "Heating curve"
},
"heating_demand": {
"name": "Heating demand"
},
"summer_mode": {
"name": "Summer mode"
}
},
"water_heater": {
"water_heater": {
"name": "Domestic hot water"
}
}
},
"exceptions": {
"set_temperature_failed": {
"message": "Failed to set the target temperature."
},
"switch_command_failed": {
"message": "Failed to send command to the heat pump."
}
}
}
@@ -0,0 +1,117 @@
"""Switch platform for Qube Heat Pump."""
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import QubeConfigEntry
from .const import DOMAIN
from .coordinator import QubeCoordinator
from .entity import QubeEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class QubeSwitchEntityDescription(SwitchEntityDescription):
"""Switch entity description for Qube Heat Pump."""
register_key: str
SWITCH_TYPES: tuple[QubeSwitchEntityDescription, ...] = (
QubeSwitchEntityDescription(
key="summer_mode",
translation_key="summer_mode",
register_key="bms_summerwinter",
),
QubeSwitchEntityDescription(
key="anti_legionella_cycle",
translation_key="anti_legionella_cycle",
register_key="antilegionella_frcstart_ant",
),
QubeSwitchEntityDescription(
key="heating_curve",
translation_key="heating_curve",
entity_category=EntityCategory.CONFIG,
register_key="en_plantsetp_compens",
),
QubeSwitchEntityDescription(
key="heating_demand",
translation_key="heating_demand",
register_key="modbus_demand",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube switches."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
QubeSwitch(coordinator, entry, description) for description in SWITCH_TYPES
)
class QubeSwitch(QubeEntity, SwitchEntity):
"""Qube switch entity."""
entity_description: QubeSwitchEntityDescription
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
description: QubeSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, entry)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}-{description.key}"
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.entity_description.register_key in self.coordinator.data.switches
)
@property
def is_on(self) -> bool | None:
"""Return true if the switch is on."""
return self.coordinator.data.switches.get(self.entity_description.register_key)
async def _async_write_switch(self, value: bool) -> None:
"""Write switch value to the device."""
register_key = self.entity_description.register_key
try:
success = await self.coordinator.client.write_switch(register_key, value)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_write_switch(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_write_switch(False)
@@ -0,0 +1,119 @@
"""Water heater platform for Qube Heat Pump."""
from typing import Any
from homeassistant.components.water_heater import (
STATE_HEAT_PUMP,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import QubeConfigEntry
from .const import DOMAIN
from .coordinator import QubeCoordinator
from .entity import QubeEntity
PARALLEL_UPDATES = 1
DHW_BOOST_KEY = "tapw_timeprogram_bms_forced"
DHW_SETPOINT_KEY = "setpoint_dhw"
DHW_MIN_TEMP = 40
DHW_MAX_TEMP = 65
OPERATION_MODES = [STATE_HEAT_PUMP, STATE_PERFORMANCE]
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube water heater."""
coordinator = entry.runtime_data.coordinator
async_add_entities([QubeWaterHeater(coordinator, entry)])
class QubeWaterHeater(QubeEntity, WaterHeaterEntity):
"""Qube DHW water heater entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = DHW_MIN_TEMP
_attr_max_temp = DHW_MAX_TEMP
_attr_operation_list = OPERATION_MODES
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_translation_key = "water_heater"
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
) -> None:
"""Initialize the water heater."""
super().__init__(coordinator, entry)
self._attr_unique_id = entry.entry_id
@property
def current_temperature(self) -> float | None:
"""Return the current DHW temperature."""
return self.coordinator.data.state.temp_dhw
@property
def target_temperature(self) -> float | None:
"""Return the target DHW temperature."""
return self.coordinator.data.state.setpoint_dhw
@property
def current_operation(self) -> str | None:
"""Return the current operation mode."""
boost = self.coordinator.data.switches.get(DHW_BOOST_KEY)
if boost is None:
return None
if boost:
return STATE_PERFORMANCE
return STATE_HEAT_PUMP
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target DHW temperature."""
temperature = kwargs.get("temperature")
if temperature is None:
return
try:
success = await self.coordinator.client.write_setpoint(
DHW_SETPOINT_KEY, temperature
)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
)
await self.coordinator.async_request_refresh()
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set the operation mode."""
boost = operation_mode == STATE_PERFORMANCE
try:
success = await self.coordinator.client.write_switch(DHW_BOOST_KEY, boost)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
)
await self.coordinator.async_request_refresh()
+38 -78
View File
@@ -12,7 +12,7 @@ from pathlib import Path
import socket
import ssl
from tempfile import NamedTemporaryFile
from typing import Any, Final, TypedDict, cast
from typing import Any, Final, cast
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter
@@ -37,7 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
@@ -60,7 +60,29 @@ from homeassistant.util.json import json_loads
from .auth import async_setup_auth
from .ban import setup_bans
from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
from .config import async_load_config
from .const import ( # noqa: F401
CONF_BASE_URL,
CONF_CORS_ORIGINS,
CONF_IP_BAN_ENABLED,
CONF_LOGIN_ATTEMPTS_THRESHOLD,
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
CONF_SSL_KEY,
CONF_SSL_PEER_CERTIFICATE,
CONF_SSL_PROFILE,
CONF_TRUSTED_PROXIES,
CONF_USE_X_FORWARDED_FOR,
CONF_USE_X_FRAME_OPTIONS,
DEFAULT_CORS,
DOMAIN,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
NO_LOGIN_ATTEMPT_THRESHOLD,
SSL_INTERMEDIATE,
SSL_MODERN,
)
from .cors import setup_cors
from .decorators import require_admin # noqa: F401
from .forwarded import async_setup_forwarded
@@ -70,38 +92,13 @@ from .security_filter import setup_security_filter
from .static import CACHE_HEADERS, CachingStaticResource
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
CONF_BASE_URL: Final = "base_url"
CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
CONF_SSL_KEY: Final = "ssl_key"
CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
CONF_SSL_PROFILE: Final = "ssl_profile"
SSL_MODERN: Final = "modern"
SSL_INTERMEDIATE: Final = "intermediate"
_LOGGER: Final = logging.getLogger(__name__)
DEFAULT_DEVELOPMENT: Final = "0"
# Cast to be able to load custom cards.
# My to be able to check url and version info.
DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
MAX_CLIENT_SIZE: Final = 1024**2 * 16
MAX_LINE_SIZE: Final = 24570
STORAGE_KEY: Final = DOMAIN
STORAGE_VERSION: Final = 1
SAVE_DELAY: Final = 180
_HAS_IPV6 = hasattr(socket, "AF_INET6")
_DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"]
@@ -154,30 +151,6 @@ _STATIC_CLASSES = {
}
class ConfData(TypedDict, total=False):
"""Typed dict for config data."""
server_host: list[str]
server_port: int
base_url: str
ssl_certificate: str
ssl_peer_certificate: str
ssl_key: str
cors_allowed_origins: list[str]
use_x_forwarded_for: bool
use_x_frame_options: bool
trusted_proxies: list[IPv4Network | IPv6Network]
login_attempts_threshold: int
ip_ban_enabled: bool
ssl_profile: str
async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return the last known working config."""
store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
return await store.async_load()
class ApiConfig:
"""Configuration settings for API server."""
@@ -201,10 +174,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# we import aiohttp_fast_zlib
(await async_import_module(hass, "aiohttp_fast_zlib")).enable()
conf: ConfData | None = config.get(DOMAIN)
# Deferred import: websocket_api declares http as its manifest
# dependency and imports back into this package at module load
# (websocket_api/http.py -> homeassistant.components.http). A top-level
# import of .websocket_api here would re-enter the still-loading
# websocket_api package and fail when applying its decorators
# (e.g. @websocket_api.require_admin).
websocket_api_module = await async_import_module(
hass, "homeassistant.components.http.websocket_api"
)
if conf is None:
conf = cast(ConfData, HTTP_SCHEMA({}))
conf = await async_load_config(hass, config)
websocket_api_module.async_register_websocket_commands(hass)
if CONF_SERVER_HOST in conf and is_hassio(hass):
issue_id = "server_host_deprecated_hassio"
@@ -271,9 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Start the server."""
with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
# We already checked it's not None.
assert conf is not None
await start_http_server_and_save_config(hass, dict(conf), server)
await server.start()
async_when_setup_or_start(hass, "frontend", start_server)
@@ -709,23 +689,3 @@ class HomeAssistantHTTP:
await self.site.stop()
if self.runner is not None:
await self.runner.cleanup()
async def start_http_server_and_save_config(
hass: HomeAssistant, conf: dict, server: HomeAssistantHTTP
) -> None:
"""Startup the http server and save the config."""
await server.start()
# If we are set up successful, we store the HTTP settings for recovery mode.
store: storage.Store[dict[str, Any]] = storage.Store(
hass, STORAGE_VERSION, STORAGE_KEY
)
if CONF_TRUSTED_PROXIES in conf:
conf[CONF_TRUSTED_PROXIES] = [
str(cast(IPv4Network | IPv6Network, ip).network_address)
for ip in conf[CONF_TRUSTED_PROXIES]
]
store.async_delay_save(lambda: conf, SAVE_DELAY)
+320
View File
@@ -0,0 +1,320 @@
"""User-managed HTTP configuration store."""
import asyncio
from ipaddress import IPv4Network, IPv6Network, ip_network
import logging
from typing import Any, Final, TypedDict, cast
import voluptuous as vol
from homeassistant.const import SERVER_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import (
CONF_BASE_URL,
CONF_CORS_ORIGINS,
CONF_IP_BAN_ENABLED,
CONF_LOGIN_ATTEMPTS_THRESHOLD,
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
CONF_SSL_KEY,
CONF_SSL_PEER_CERTIFICATE,
CONF_SSL_PROFILE,
CONF_TRUSTED_PROXIES,
CONF_USE_X_FORWARDED_FOR,
CONF_USE_X_FRAME_OPTIONS,
DEFAULT_CORS,
DOMAIN,
NO_LOGIN_ATTEMPT_THRESHOLD,
SSL_INTERMEDIATE,
SSL_MODERN,
)
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY: Final = DOMAIN
STORAGE_VERSION: Final = 2
KEY_STABLE: Final = "stable"
KEY_PENDING: Final = "pending"
KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done"
DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY)
class ConfData(TypedDict, total=False):
"""Typed dict for the validated HTTP config (matches ``HTTP_STORAGE_SCHEMA``)."""
server_host: list[str]
server_port: int
ssl_certificate: str
ssl_peer_certificate: str
ssl_key: str
cors_allowed_origins: list[str]
use_x_forwarded_for: bool
trusted_proxies: list[IPv4Network | IPv6Network]
login_attempts_threshold: int
ip_ban_enabled: bool
ssl_profile: str
use_x_frame_options: bool
class _HTTPStoreData(TypedDict):
"""Data structure for HTTP config storage."""
stable: ConfData
pending: ConfData | None
yaml_migration_done: bool
def _ip_network_str(value: Any) -> str:
"""Validate the value is a valid IP network and return its string form."""
return str(ip_network(value))
HTTP_STORAGE_SCHEMA: Final = vol.Schema(
{
# YAML used to allow base_url (deprecated); strip it on the way in so
# the stored config never contains it.
vol.Remove(CONF_BASE_URL): object,
vol.Optional(CONF_SERVER_HOST): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
vol.Optional(CONF_SSL_KEY): cv.isfile,
vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
cv.ensure_list, [cv.string]
),
vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean,
vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All(
cv.ensure_list, [_ip_network_str]
),
vol.Optional(
CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),
vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean,
vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
[SSL_INTERMEDIATE, SSL_MODERN]
),
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
}
)
_DEFAULT_CONFIG: Final[ConfData] = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData:
"""Load the HTTP config to apply on this startup.
YAML config is only migrated once. Subsequent boots will ignore YAML and
use the store exclusively.
Resolution order:
- Recovery mode: always use ``stable`` so HA stays reachable after a bad
config; YAML is ignored entirely (any pending YAML migration is
deferred to the next normal boot).
- Normal mode: prefer ``pending`` if set, otherwise ``stable``.
"""
store = await async_get_and_load_store(hass)
if hass.config.recovery_mode:
_LOGGER.info("Recovery mode active; using stable HTTP config")
return store.stable
yaml_conf: ConfData | None = config.get(DOMAIN)
if store.yaml_migration_done:
if yaml_conf is not None:
# YAML is still present after migration completed; surface a repair
# issue so the user knows their YAML is being ignored.
ir.async_create_issue(
hass,
DOMAIN,
"yaml_still_present_after_migration",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="yaml_still_present_after_migration",
)
else:
# Clear any leftover deprecation issues if YAML was removed after migration.
ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml_import_error")
ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml")
ir.async_delete_issue(hass, DOMAIN, "yaml_still_present_after_migration")
else:
# Migrate YAML to storage and use it directly for this start. The
# migration function also marks the migration as done so future
# starts will ignore any remaining YAML.
conf_in_yaml = yaml_conf is not None
if yaml_conf is None:
yaml_conf = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
try:
await store.async_migrate_yaml(yaml_conf)
except Exception:
_LOGGER.exception("Failed to migrate HTTP YAML configuration to storage")
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_error",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="deprecated_yaml_import_error",
)
else:
if conf_in_yaml:
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2027.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
if store.pending is not None:
_LOGGER.info("Using pending HTTP config")
return store.pending
_LOGGER.info("Using stable HTTP config")
return store.stable
async def async_get_and_load_store(hass: HomeAssistant) -> HTTPConfigStore:
"""Return the singleton HTTP config store and load it."""
if (store := hass.data.get(DATA_STORE)) is None:
store = HTTPConfigStore(hass)
hass.data[DATA_STORE] = store
await store.async_load()
return store
class HTTPConfigStore:
"""Persist HTTP config as a stable/pending pair.
``stable`` holds the last config the user confirmed as working;
``pending`` holds an unconfirmed config the user wants to try on
the next start. Normal startup prefers ``pending`` so the new
config gets exercised; recovery mode falls back to ``stable`` so
Home Assistant can still come up after a bad config.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the store."""
self._hass = hass
self._store = _HTTPStore(
hass,
STORAGE_VERSION,
STORAGE_KEY,
private=True,
atomic_writes=True,
)
self._stable: ConfData = _DEFAULT_CONFIG
self._pending: ConfData | None = None
self._yaml_migration_done = False
self._loaded = False
self._load_lock = asyncio.Lock()
@property
def stable(self) -> ConfData:
"""Return the last confirmed-working config."""
return self._stable
@property
def pending(self) -> ConfData | None:
"""Return the unconfirmed config awaiting promotion, if any."""
return self._pending
@property
def yaml_migration_done(self) -> bool:
"""Return whether the YAML migration has been completed."""
return self._yaml_migration_done
async def async_load(self) -> None:
"""Load the stable and pending configs from disk."""
if self._loaded:
return
async with self._load_lock:
if self._loaded:
# Another coroutine may have loaded the config while we were waiting
# for the lock; check again to avoid unnecessary disk I/O.
return # type: ignore[unreachable]
raw = await self._store.async_load()
if raw is not None:
self._stable = raw[KEY_STABLE]
self._pending = raw[KEY_PENDING]
self._yaml_migration_done = raw[KEY_YAML_MIGRATION_DONE]
self._loaded = True
async def async_set_pending(self, config: ConfData | None) -> None:
"""Set (or clear) the pending config."""
await self.async_load()
if config == self.stable:
# No need to save a pending config that is the same as stable.
config = None
self._pending = config
await self._async_persist()
async def async_promote_pending(self) -> None:
"""Promote the pending config to stable.
Raises ``HomeAssistantError`` if there is nothing to promote.
"""
await self.async_load()
if self._pending is None:
raise HomeAssistantError("No pending HTTP config to promote")
self._stable = self._pending
self._pending = None
await self._async_persist()
async def async_migrate_yaml(self, config: ConfData) -> None:
"""Migrate YAML config to storage as pending if not the same as the config used for recovery."""
await self.async_load()
validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config))
self._pending = None if validated_config == self._stable else validated_config
self._yaml_migration_done = True
await self._async_persist()
async def _async_persist(self) -> None:
"""Write the current state to disk (or remove the file if empty)."""
await self._store.async_save(
{
KEY_STABLE: self._stable,
KEY_PENDING: self._pending,
KEY_YAML_MIGRATION_DONE: self._yaml_migration_done,
}
)
class _HTTPStore(Store[_HTTPStoreData]):
"""Http store."""
async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
if old_major_version == 1:
# Run the v1 payload through the storage schema so the v2 ``stable``
# slot is well-formed (all keys present, values normalised) and the
# load step can rely on direct key access.
try:
stable = HTTP_STORAGE_SCHEMA(old_data)
except vol.Invalid:
_LOGGER.warning(
"Discarding invalid v1 HTTP config during migration; "
"falling back to defaults"
)
stable = HTTP_STORAGE_SCHEMA({})
return {
KEY_STABLE: stable,
KEY_PENDING: None,
KEY_YAML_MIGRATION_DONE: False,
}
return old_data
+24
View File
@@ -11,6 +11,30 @@ DOMAIN: Final = "http"
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
CONF_BASE_URL: Final = "base_url"
CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
CONF_SSL_KEY: Final = "ssl_key"
CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
CONF_SSL_PROFILE: Final = "ssl_profile"
SSL_MODERN: Final = "modern"
SSL_INTERMEDIATE: Final = "intermediate"
# Cast to be able to load custom cards.
# My to be able to check url and version info.
DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
ATTR_CONFIG = "config"
def is_supervisor_unix_socket_request(request: Request) -> bool:
"""Check if request arrived over the Supervisor Unix socket."""
@@ -1,5 +1,13 @@
{
"issues": {
"deprecated_yaml": {
"description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.",
"title": "The HTTP YAML configuration is deprecated"
},
"deprecated_yaml_import_error": {
"description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.",
"title": "Failed to import HTTP YAML configuration"
},
"server_host_deprecated_hassio": {
"description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
@@ -7,6 +15,10 @@
"ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL"
},
"yaml_still_present_after_migration": {
"description": "The HTTP configuration in `configuration.yaml` has already been migrated and is now being ignored. Please remove the `http:` block from your `configuration.yaml`. Manage the HTTP configuration from the UI under **Settings** > **System** > **Network**.",
"title": "HTTP YAML configuration is ignored after migration"
}
}
}
@@ -0,0 +1,85 @@
"""WebSocket API for the HTTP integration user config."""
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store
from .const import ATTR_CONFIG
@callback
def async_register_websocket_commands(hass: HomeAssistant) -> None:
"""Register the HTTP config websocket commands."""
websocket_api.async_register_command(hass, websocket_get_config)
websocket_api.async_register_command(hass, websocket_set_config)
websocket_api.async_register_command(hass, websocket_promote_config)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "http/config"})
@websocket_api.async_response
async def websocket_get_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the active HTTP configuration (the confirmed-working ``stable`` slot)."""
store = await async_get_and_load_store(hass)
connection.send_result(msg["id"], store.stable)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "http/config/configure",
vol.Required(ATTR_CONFIG): vol.Any(None, HTTP_STORAGE_SCHEMA),
}
)
@websocket_api.async_response
async def websocket_set_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Store a new pending HTTP configuration.
The new config is not applied until Home Assistant is restarted
and the user promotes it via ``http/config/promote``. Until then
the existing ``stable`` config remains the recovery fallback.
"""
store = await async_get_and_load_store(hass)
await store.async_set_pending(msg[ATTR_CONFIG])
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "http/config/promote"})
@websocket_api.async_response
async def websocket_promote_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Promote the pending HTTP config to stable.
Called by the user after they have verified Home Assistant is
working correctly with the pending config. The stable config is
the one used by recovery mode, so promotion must be explicit.
"""
store = await async_get_and_load_store(hass)
try:
await store.async_promote_pending()
except HomeAssistantError as err:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_ALLOWED,
str(err),
)
return
connection.send_result(msg["id"])
@@ -46,6 +46,11 @@ async def async_setup_entry(
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
hass, config_entry, hydrawise, main_coordinator
)
# async_track_zones is registered first on water_use_coordinator,
# so the water-use coordinator's data is in sync before
# callbacks below construct entities for newly added zones.
water_use_coordinator.async_track_zones()
main_coordinator.async_track_zones()
await water_use_coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = HydrawiseUpdateCoordinators(
main=main_coordinator,
@@ -82,6 +82,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
self.new_zones_callbacks: list[
Callable[[Iterable[tuple[Zone, Controller]]], None]
] = []
@callback
def async_track_zones(self) -> None:
"""Begin tracking zone and controller add/remove on updates."""
self.async_add_listener(self._add_remove_zones)
async def _async_update_data(self) -> HydrawiseData:
@@ -198,6 +202,23 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
self.api = api
self._main_coordinator = main_coordinator
@callback
def async_track_zones(self) -> None:
"""Begin tracking zone and controller add/remove on updates."""
self._main_coordinator.async_add_listener(self._sync_data_from_main)
@callback
def _sync_data_from_main(self) -> None:
"""Sync data references from the main coordinator after it updates."""
if self.data is None or self._main_coordinator.data is None:
return # type: ignore[unreachable]
main_data = self._main_coordinator.data
self.data.user = main_data.user
self.data.controllers = main_data.controllers
self.data.zones = main_data.zones
self.data.zone_id_to_controller = main_data.zone_id_to_controller
self.data.sensors = main_data.sensors
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
+6 -1
View File
@@ -3,4 +3,9 @@
from datetime import timedelta
DOMAIN = "iaqualink"
UPDATE_INTERVAL = timedelta(seconds=15)
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
"iaqua": timedelta(seconds=15),
"exo": timedelta(seconds=60),
}
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)

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