Compare commits

..

134 Commits

Author SHA1 Message Date
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
Ronald van der Meer ff69557b17 Bump python-duco-client to 0.4.1 (#169991) 2026-05-07 13:26:22 +02:00
G Johansson 3b93ccc7ba Fix double reloading in unifi (#155147)
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 13:09:30 +02:00
G Johansson f886b60e2c Deprecate use of config entry listener with reloading methods in config entries (#169198)
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 11:51:24 +02:00
Marc Mueller d0f126f945 Update mypy to 2.0 (#169960) 2026-05-07 11:41:48 +02:00
epenet ce5f2330eb Read Tuya device info from quirk (#169888) 2026-05-07 11:28:11 +02:00
Erik Montnemery 427758ef15 Filter excluded states in entity trigger base class (#169956) 2026-05-07 10:30:33 +02:00
Daniel Hjelseth Høyer c2ce313ec8 Bump pyTibber to 0.37.5 (#169981)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:41:08 +02:00
Zoltán Farkasdi b8ba1c123d netatmo: add doortag direct category fetch (#169711)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-07 09:18:39 +02:00
Daniel Hjelseth Høyer 10f1cbb51e Migrate mill to use entry.runtime_data (#169948)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:15:21 +02:00
Christian Lackas e3bcce06bf Bump PyViCare to 2.60.2 (#169918)
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-05-07 08:30:41 +02:00
Kamil Breguła 4e0472feb5 Add fixture for Tuya camera (knkaf1d0dytgyhix) (#169967)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-05-07 07:33:28 +02:00
Jan Bouwhuis 046298f2ca No need for a local import of the paho mqtt client (#169925) 2026-05-06 22:45:36 +02:00
Jan Bouwhuis c92128b282 Remove advanced setting dependency for IMAP integration (#169827) 2026-05-06 22:37:27 +02:00
Christian Lackas 886e66e7e3 Bump homematicip to 2.10.0 (#169950) 2026-05-06 22:20:16 +02:00
Erik Montnemery 7da49570b5 Add support for options to todo triggers (#169947) 2026-05-06 22:16:55 +02:00
G Johansson b8baa3271b Bump holidays to 0.96 (#169939) 2026-05-06 22:08:38 +02:00
Erik Montnemery 65bc4bf1d0 Add missing trigger and condition tests (#169945) 2026-05-06 21:53:40 +02:00
Erik Montnemery 27a8d185c9 Add StatelessEntityTriggerBase base class (#169937) 2026-05-06 21:43:29 +02:00
Andriy Kushnir 1e5992f2b5 Remove myself as codeowner for roomba (#169922) 2026-05-06 20:33:15 +02:00
puddly ac84a14846 Bump serialx to 1.7.1 (#169928) 2026-05-06 21:04:13 +03:00
Robert Resch fa265b18ce Shorten docker publish job name (#169926) 2026-05-06 18:12:13 +02:00
Stefan Agner 38634ddd55 Fix hassio auth IndexError on Supervisor Unix socket requests (#169911) 2026-05-06 17:48:35 +02:00
Joakim Plate 13dd831874 Update gardena ble to 2.8.1 (#169914) 2026-05-06 16:25:37 +02:00
Tom Wilkie 3be5906398 Register Hive Hub MAC address as device connection (#169040)
Signed-off-by: Tom Wilkie <tom.wilkie@gmail.com>
2026-05-06 16:12:59 +02:00
Erik Montnemery cef918d6f8 Remove _get_tracked_value method from EntityConditionBase (#169906) 2026-05-06 14:59:57 +02:00
Jan Bouwhuis 19aa1b6578 Remove advanced options dependency from MQTT integration (#169833) 2026-05-06 14:52:07 +02:00
Daniel Hjelseth Høyer b0eb69936e Bump pyTibber to 0.37.4 (#169907) 2026-05-06 14:47:10 +02:00
Erik Montnemery b6096a71d1 Exclude incompatible humidifier entities from humidifier automations (#169905) 2026-05-06 14:44:30 +02:00
Erik Montnemery 059d7011ba Exclude incompatible water_heater entities from water_heater automations (#169904) 2026-05-06 14:44:19 +02:00
epenet bbe00ef79e De-duplicate code to build Tuya device info (#169899) 2026-05-06 14:29:47 +02:00
Erik Montnemery 7f447abc3a Exclude incompatible climate entities from climate automations (#169903) 2026-05-06 14:18:14 +02:00
Erik Montnemery 923e099467 Unload scripts and conditions created by template entities (#169366) 2026-05-06 14:11:37 +02:00
Erik Montnemery 26714c6d9f Add media_player volume condition (#169897) 2026-05-06 13:15:01 +02:00
Erik Montnemery 5f1201dbbe Exclude incompatible entities from temperature automations (#169901) 2026-05-06 13:10:53 +02:00
Erik Montnemery 52e1d9443c Exclude incompatible entities from humidity automations (#169898) 2026-05-06 13:10:24 +02:00
Manu 824f5205e9 Record notification from legacy notify action in Mobile App (#169749) 2026-05-06 12:57:57 +02:00
Erik Montnemery cf8bc55add Add media_player muted conditions (#169892) 2026-05-06 12:38:05 +02:00
Bram Kragten 1e9244f4fc Update frontend to 20260429.3 (#169893) 2026-05-06 12:19:24 +02:00
Tom Matheussen be4f4928d5 Bump satel_integra to 1.3.1 (#169889) 2026-05-06 11:27:14 +02:00
Erik Montnemery 80f6f8ee31 Improve entity trigger tests (#169881) 2026-05-06 10:48:36 +02:00
Erik Montnemery 267d52491a Add media_player volume triggers (#169885) 2026-05-06 10:48:10 +02:00
Ludovic BOUÉ ee84d625cd Expose SET_SPEED for all fans via PercentSetting in Matter (#169696)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-05-06 10:16:31 +02:00
dependabot[bot] 5d091d25d5 Bump j178/prek-action from 2.0.2 to 2.0.3 (#169882) 2026-05-06 09:50:18 +02:00
Erik Montnemery 97b5f1cf64 Add method _should_include to EntityConditionBase (#169884)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-06 09:49:22 +02:00
Zoltán Farkasdi d89bcd83d9 netatmo: bump pyatmo v9.4.0 (#169735)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-06 09:16:22 +02:00
Joost Lekkerkerker 073b20c4b2 Fix Zinvolt select options (#169886) 2026-05-06 09:09:24 +02:00
epenet 2af9405750 Cleanup unused code in Tuya util (#169883) 2026-05-06 08:42:05 +02:00
Erik Montnemery 10084c8c0c Add trigger timer.time_remaining (#169763) 2026-05-05 23:54:49 -04:00
Erik Montnemery 7e8f5365ce Add method _should_include to EntityTriggerBase (#169837) 2026-05-06 00:50:22 +02:00
Erik Montnemery 65f9dcd7bf Improve condition test helper docstrings (#169871) 2026-05-06 00:32:37 +02:00
epenet 4c8f37fef6 Bump tuya-device-handlers to 0.0.19 (#169848) 2026-05-05 22:23:14 +02:00
Erik Montnemery d1295fa260 Validate yaml matches implementation in automation options_supported tests (#169798) 2026-05-05 22:20:28 +02:00
Diogo Gomes 9b2eea920f Add V2C LED lights (#169778)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 22:19:59 +02:00
Petro31 c81c1cbb14 Remove legacy weather template entities (#169734) 2026-05-05 22:18:46 +02:00
Erik Montnemery 11ee05874a Improve trigger test helper docstrings (#169869) 2026-05-05 22:11:08 +02:00
puddly 7d7c47b56e Bump serialx to 1.7.0 (#169867) 2026-05-05 21:06:30 +02:00
epenet dc4210595f Fix flaky test_set_scan_interval_via_platform (#169856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:49:15 +02:00
Freekers 7430366d9b Enable web search support for gpt-5-nano (#169710) 2026-05-05 20:47:52 +03:00
Crocmagnon ae3bd54ca7 switchbot: remove unwanted future annotations import preventing build on all new PRs (#169863) 2026-05-05 19:40:27 +02:00
Glenn Waters e3ce7fb000 Bump elkm1-lib to 2.2.15 (#169843)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:50:17 +02:00
epenet 9286b517d3 Add ruff rule to prevent __future__ annotations (#169852)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:42:10 +02:00
elgris 4d62e4765d Add a number entity to set display time offset (in minutes) for Switchbot Meter CO2 devices. (#169603)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 17:45:47 +02:00
Michael Hansen ea55ef90a6 Bump intents to 2026.5.5 (#169855) 2026-05-05 18:22:22 +03:00
epenet 751765b97b Cleanup from __future__ import annotations (#169850) 2026-05-05 16:35:21 +02:00
Denis Shulyaka 11ed1fe20f Return the requested format for OpenAI TTS (#169839)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 10:28:20 -04:00
Joost Lekkerkerker 9b5166769a Add Sensereo matter brand (#169836) 2026-05-05 10:18:01 -04:00
Joost Lekkerkerker 70c2a323ce Add Zunzunbee Zigbee brand (#169838) 2026-05-05 10:17:49 -04:00
Ronald van der Meer 0ec5d6b273 Add API version to Duco diagnostics for support triage (#169802) 2026-05-05 15:48:43 +02:00
Robert Resch b1e8dc2ebb Remove show_advanced_options in Ecovacs and always show all options (#169831) 2026-05-05 15:42:08 +02:00
Artur Pragacz e144804d28 Fix async_unload teardown race in scripts (#169562) 2026-05-05 15:03:37 +02:00
cengelen 8521a49986 Bump growatt server to 2.1.0 (#169495)
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 14:11:50 +02:00
Raj Laud 3587f9613f Bump victron-ble-ha-parser to 0.7.0 (#169736)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 13:57:19 +02:00
Jan Bouwhuis 2f1dd3a817 Deprecate MQTT protocol versions 3.x and migrate to version 5 (#169759)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 13:43:18 +02:00
wollew 2c2e8db19f Remove deprecated reboot service for Velux gateway (#169796) 2026-05-05 11:08:00 +02:00
Erik Montnemery 64a3f91132 Improve template reload (#169480) 2026-05-05 10:16:22 +02:00
dependabot[bot] bd61c893e4 Bump dawidd6/action-download-artifact from 20 to 21 (#169793)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:12:07 +02:00
renovate[bot] 6bb759b887 Update infrared-protocols to 2.1.0 (#169785)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 10:11:52 +02:00
Matthias Alphart 280b5ef388 Update xknxproject to 3.9.0 (#169775) 2026-05-05 10:09:24 +02:00
Erik Montnemery 416d4e02a0 Add trigger media_player.unmuted (#169797) 2026-05-05 09:45:45 +02:00
kw6423 c99f261a2d Restore OwnTracks custom device tracker attributes (#169753)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-05-05 09:44:53 +02:00
Thomas D 9c9a058eb0 Add missing initialization charging power status option to Volvo (#169727) 2026-05-05 09:10:13 +02:00
Nathan Spencer 7b51b929ef Bump pylitterbot to 2025.4.0 (#169652) 2026-05-05 09:05:16 +02:00
Ronald van der Meer 74971ebcd1 Bump python-duco-client to 0.4.0 (#169776) 2026-05-05 08:55:22 +02:00
Åke Strandberg 1f5d80ca44 Add missing code for miele washing machine (#169795) 2026-05-05 08:54:12 +02:00
Erik Montnemery 9075c6a5cb Add trigger media_player.muted (#156736) 2026-05-05 08:22:03 +02:00
Manu ab4162601f Remove YAML import from Duck DNS integration (#169769) 2026-05-05 07:45:40 +02:00
HoffmanEl 38de48ac9d Add data_description to airnow config flow strings (#169783) 2026-05-05 07:43:18 +02:00
Nikolai Rahimi 597d9a2ada Add Mitsubishi Comfort integration (#167472)
Co-authored-by: Nikolai Rahimi <nikolairahimi@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-05 00:16:00 +02:00
optimusbasti 71494b6c97 Bump aioautomower to 2.7.5 (#169758) 2026-05-04 22:27:46 +01:00
A. Gideonse 57e66baf53 Update Indevolt integration quality scale to silver (#167843)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-04 23:05:11 +02:00
Nathan Spencer 63dfc97346 Limit power status binary sensor to non-LR5 devices (#169659) 2026-05-04 22:51:17 +02:00
shbatm 1b4a7d55c0 Add precipitation device class to WeatherFlow Cloud accumulation sensors (#169638)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 22:29:12 +02:00
Matthew Gibson 8c8a863867 Add ptdevices Integration (#156307)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-04 22:15:52 +02:00
Keilin Bickar 28d65e987c bump sense-energy to 0.14.1 (#169761) 2026-05-04 21:22:45 +02:00
Daniel Hjelseth Høyer d0c0f02311 Bump pyTibber to 0.37.3 (#169762) 2026-05-04 21:21:57 +02:00
kernelpanic85 f90e9ceb6c Add Celsius and Fahrenheit to Smartthings UNITS mapping (#169686) 2026-05-04 21:20:04 +02:00
G Johansson 553ba5e7ab Add binary sensor to Nord Pool (#169684) 2026-05-04 21:10:06 +02:00
Erwin Douna 6633f16d13 Add system health to Portainer (#169698) 2026-05-04 21:07:16 +02:00
Kamil Breguła 1beeecdf04 Use SensorDeviceClass.UPTIME in WLED (#169708)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-05-04 21:02:15 +02:00
G Johansson 6319b3b4ef Raise repairs on platform setup for command_line (#153565)
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 20:59:28 +02:00
Steve Syrell 2ed550c2c9 Bump Insteon-panel to 0.6.2 (#169757) 2026-05-04 20:55:41 +02:00
Mike Degatano 6f28902a4f Refactor hassio coordinators to use typed dataclasses instead of dicts (#168847)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-04 20:24:33 +02:00
optimusbasti fcd23353f2 Add set_cover_position_and_tilt service to Overkiz (#169275)
Co-authored-by: optimusbasti <optimusbasti@users.noreply.github.com>
Co-authored-by: ThomasCZ <noreply@users.github.com>
2026-05-04 20:23:26 +02:00
Leonardo Rivera 2846dcc035 Add delete service action to OneDrive integration (#168064)
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-04 20:17:41 +02:00
Christian Lackas 5858db1cda Use all_devices in ViCare diagnostics for completeness (#169429) 2026-05-04 19:56:36 +02:00
Diogo Gomes 1140d52735 Bump pytrydan to 1.0.0 (#169742) 2026-05-04 19:39:46 +02:00
G Johansson 664354c4fe Fix config flow validation in Nord Pool (#169751) 2026-05-04 19:34:17 +02:00
Petro31 dfb8c7edb8 Fix uptime template sensor (#169743) 2026-05-04 18:09:46 +01:00
Cristoforo Cervino c22edbec30 Add opening/closing state icons to valve domain (#169644) 2026-05-04 18:42:21 +02:00
kw6423 86415c1906 OwnTracks: expose message tst as update_timestamp in device_tracker attribute (#165203)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-05-04 18:35:18 +02:00
Paul Bottein e4f8d1ac64 Update frontend to 20260429.2 (#169748) 2026-05-04 12:22:51 -04:00
Tom 3f97230c25 Improve ProxmoxVE config flow preparing bug fixing (#169682)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-05-04 17:20:25 +02:00
Simone Chemelli 14aa87f026 Bump pyuptimerobot to 25.0.0 (#169572) 2026-05-04 16:13:55 +01:00
429 changed files with 15757 additions and 5303 deletions
+3 -3
View File
@@ -108,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -119,7 +119,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish meta container for ${{ matrix.registry }}
name: Publish to ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
+2
View File
@@ -423,6 +423,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.*
@@ -442,6 +443,7 @@ homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
homeassistant.components.proximity.*
homeassistant.components.prusalink.*
homeassistant.components.ptdevices.*
homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
Generated
+10 -4
View File
@@ -851,8 +851,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
@@ -1092,6 +1092,8 @@ CLAUDE.md @home-assistant/core
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/mitsubishi_comfort/ @nikolairahimi
/tests/components/mitsubishi_comfort/ @nikolairahimi
/homeassistant/components/moat/ @bdraco
/tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core
@@ -1306,6 +1308,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
@@ -1378,6 +1382,8 @@ CLAUDE.md @home-assistant/core
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978
/tests/components/ptdevices/ @ParemTech-Inc @frogman85978
/homeassistant/components/pterodactyl/ @elmurato
/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
@@ -1491,8 +1497,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.7.1"]
}
+7 -1
View File
@@ -17,7 +17,13 @@
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
},
"description": "To generate API key go to {api_key_url}"
"data_description": {
"api_key": "To generate an API key, go to {api_key_url}.",
"latitude": "The latitude of your location.",
"longitude": "The longitude of your location.",
"radius": "The radius in miles around your location to search for reporting stations."
},
"description": "To generate an API key, go to {api_key_url}."
}
}
},
@@ -899,12 +899,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
self.action_script.async_unload()
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+3 -23
View File
@@ -1,36 +1,16 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
class ButtonPressedTrigger(StatelessEntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+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
)
+23 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,12 +59,33 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -88,10 +109,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
+38 -10
View File
@@ -8,14 +8,15 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -55,6 +56,13 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -75,6 +83,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -83,14 +117,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -3,7 +3,10 @@
import asyncio
from datetime import datetime, timedelta
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
)
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -25,6 +28,7 @@ from homeassistant.util import dt as dt_util
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .sensor import CommandSensorData
from .utils import create_platform_yaml_not_supported_issue
DEFAULT_NAME = "Binary Command Sensor"
DEFAULT_PAYLOAD_ON = "ON"
@@ -41,6 +45,7 @@ async def async_setup_platform(
) -> None:
"""Set up the Command line Binary Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, BINARY_SENSOR_DOMAIN)
return
binary_sensor_config = discovery_info
@@ -4,7 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.cover import CoverEntity
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverEntity
from homeassistant.const import (
CONF_COMMAND_CLOSE,
CONF_COMMAND_OPEN,
@@ -26,7 +26,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import async_call_shell_with_timeout, async_check_output_or_log
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
SCAN_INTERVAL = timedelta(seconds=15)
@@ -39,6 +43,7 @@ async def async_setup_platform(
) -> None:
"""Set up cover controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, COVER_DOMAIN)
return
covers = []
@@ -4,25 +4,29 @@ import logging
import subprocess
from typing import Any
from homeassistant.components.notify import BaseNotificationService
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
BaseNotificationService,
)
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .utils import render_template_args
from .utils import create_platform_yaml_not_supported_issue, render_template_args
_LOGGER = logging.getLogger(__name__)
def get_service(
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> CommandLineNotificationService | None:
"""Get the Command Line notification service."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, NOTIFY_DOMAIN)
return None
notify_config = discovery_info
@@ -8,6 +8,7 @@ from typing import Any
from jsonpath import jsonpath
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -32,7 +33,11 @@ from .const import (
LOGGER,
TRIGGER_ENTITY_OPTIONS,
)
from .utils import async_check_output_or_log, render_template_args
from .utils import (
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
render_template_args,
)
DEFAULT_NAME = "Command Sensor"
@@ -47,6 +52,7 @@ async def async_setup_platform(
) -> None:
"""Set up the Command Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SENSOR_DOMAIN)
return
sensor_config = discovery_info
@@ -1,4 +1,10 @@
{
"issues": {
"platform_yaml_not_supported": {
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
"title": "Platform YAML is not supported in Command Line"
}
},
"services": {
"reload": {
"description": "Reloads command line configuration from the YAML-configuration.",
@@ -4,7 +4,11 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
ENTITY_ID_FORMAT,
SwitchEntity,
)
from homeassistant.const import (
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
@@ -25,7 +29,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import async_call_shell_with_timeout, async_check_output_or_log
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
SCAN_INTERVAL = timedelta(seconds=30)
@@ -38,6 +46,7 @@ async def async_setup_platform(
) -> None:
"""Find and return switches controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SWITCH_DOMAIN)
return
switches = []
+18 -1
View File
@@ -4,9 +4,10 @@ import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from .const import LOGGER
from .const import DOMAIN, LOGGER
_EXEC_FAILED_CODE = 127
@@ -91,3 +92,19 @@ def render_template_args(hass: HomeAssistant, command: str) -> str | None:
LOGGER.debug("Running command: %s", command)
return command
def create_platform_yaml_not_supported_issue(
hass: HomeAssistant, platform_domain: str
) -> None:
"""Create an issue when platform yaml is used."""
async_create_issue(
hass,
DOMAIN,
f"{platform_domain}_platform_yaml_not_supported",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="platform_yaml_not_supported",
translation_placeholders={"platform": platform_domain},
learn_more_url="https://www.home-assistant.io/integrations/command_line/",
)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
}
+3 -18
View File
@@ -1,11 +1,6 @@
"""Provides triggers for counters."""
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -41,9 +36,7 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
"""Check that the counter value decreased."""
return int(from_state.state) > int(to_state.state)
@@ -51,9 +44,7 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
"""Check that the counter value increased."""
return int(from_state.state) < int(to_state.state)
@@ -62,12 +53,6 @@ class CounterValueBaseTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
+2 -4
View File
@@ -2,7 +2,7 @@
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
@@ -28,9 +28,7 @@ class CoverTriggerBase(EntityTriggerBase):
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
"""Check that the relevant cover value changed."""
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
+4 -23
View File
@@ -6,38 +6,19 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
class DoorbellRangTrigger(EntityTriggerBase):
class DoorbellRangTrigger(StatelessEntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
TRIGGERS: dict[str, type[Trigger]] = {
+1 -25
View File
@@ -2,10 +2,6 @@
import logging
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -16,18 +12,7 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_ACCESS_TOKEN): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -35,15 +20,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_setup_services(hass)
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
@@ -16,7 +16,6 @@ from homeassistant.helpers.selector import (
from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -68,18 +67,6 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"url": "https://www.duckdns.org/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
+1 -35
View File
@@ -1,45 +1,11 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.6.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Duck DNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)
def action_called_without_config_entry(hass: HomeAssistant) -> None:
"""Deprecate the use of action without config entry."""
@@ -49,10 +49,6 @@
"deprecated_call_without_config_entry": {
"description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.",
"title": "Detected deprecated use of action without config entry"
},
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"
}
},
"services": {
@@ -13,6 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -31,9 +34,15 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
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()
@@ -43,10 +52,15 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.10"],
"requirements": ["python-duco-client==0.4.1"],
"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][]].*",
@@ -137,10 +137,6 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
@@ -72,6 +72,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.13"]
"requirements": ["elkm1-lib==2.2.15"]
}
+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))
+3 -1
View File
@@ -199,7 +199,9 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
self._attr_native_value = (
None if self._element.value is None else str(self._element.value)
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.14.0"]
"requirements": ["sense-energy==0.14.1"]
}
+5 -18
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(EntityTriggerBase):
class EventReceivedTrigger(StatelessEntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,22 +39,9 @@ class EventReceivedTrigger(EntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
TRIGGERS: dict[str, type[Trigger]] = {
@@ -1,7 +1,5 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
import asyncio
from typing import Any
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.1"]
"requirements": ["home-assistant-frontend==20260429.3"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.8.1"]
}
@@ -596,7 +596,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(settings_data=self.data)
return self.api.sph_read_ac_charge_times(
self.device_id, settings_data=self.data
)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
@@ -609,4 +611,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
return self.api.sph_read_ac_discharge_times(
self.device_id, settings_data=self.data
)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==1.9.0"]
"requirements": ["growattServer==2.1.0"]
}
+13 -8
View File
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -41,14 +42,18 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
@@ -1,8 +1,9 @@
"""Binary sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
import itertools
from aiohasupervisor.models import AddonState
from aiohasupervisor.models.mounts import MountState
from homeassistant.components.binary_sensor import (
@@ -14,41 +15,46 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ADDONS_COORDINATOR,
ATTR_STARTED,
ATTR_STATE,
DATA_KEY_ADDONS,
DATA_KEY_MOUNTS,
MAIN_COORDINATOR,
)
from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR
from .entity import HassioAddonEntity, HassioMountEntity
@dataclass(frozen=True)
class HassioBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hassio binary sensor entity description."""
@dataclass(frozen=True, kw_only=True)
class HassioAddonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io add-on binary sensor entity description."""
target: str | None = None
value_fn: Callable[[HassioAddonBinarySensor], bool]
@dataclass(frozen=True, kw_only=True)
class HassioMountBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Hass.io mount binary sensor entity description."""
value_fn: Callable[[HassioMountBinarySensor], bool]
ADDON_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
HassioAddonBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
entity_registry_enabled_default=False,
key=ATTR_STATE,
key="state",
translation_key="state",
target=ATTR_STARTED,
value_fn=lambda entity: (
entity.coordinator.data.addons[entity.addon_slug].addon.state
== AddonState.STARTED
),
),
)
MOUNT_ENTITY_DESCRIPTIONS = (
HassioBinarySensorEntityDescription(
HassioMountBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
key=ATTR_STATE,
key="state",
translation_key="mount",
target=MountState.ACTIVE.value,
value_fn=lambda entity: (
entity.coordinator.data.mounts[entity.mount_name].state == MountState.ACTIVE
),
),
)
@@ -63,57 +69,46 @@ async def async_setup_entry(
coordinator = hass.data[MAIN_COORDINATOR]
async_add_entities(
itertools.chain(
[
[
*[
HassioAddonBinarySensor(
addon=addon,
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.addons.values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
],
[
*[
HassioMountBinarySensor(
mount=mount,
coordinator=coordinator,
entity_description=entity_description,
)
for mount in coordinator.data[DATA_KEY_MOUNTS].values()
for mount in coordinator.data.mounts.values()
for entity_description in MOUNT_ENTITY_DESCRIPTIONS
],
)
]
)
class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity):
"""Binary sensor for Hass.io add-ons."""
entity_description: HassioBinarySensorEntityDescription
entity_description: HassioAddonBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
if self.entity_description.target is None:
return value
return value == self.entity_description.target
return self.entity_description.value_fn(self)
class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity):
"""Binary sensor for Hass.io mount."""
entity_description: HassioBinarySensorEntityDescription
entity_description: HassioMountBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
value = getattr(
self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name],
self.entity_description.key,
)
if self.entity_description.target is None:
return value
return value == self.entity_description.target
return self.entity_description.value_fn(self)
+8 -2
View File
@@ -8,9 +8,11 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from aiohasupervisor.models import (
AddonsStats,
HomeAssistantInfo,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
OSInfo,
RootInfo,
@@ -112,8 +114,12 @@ DATA_OS_INFO: HassKey[OSInfo] = HassKey("hassio_os_info")
DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info")
DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info")
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_ADDONS_INFO: HassKey[dict[str, InstalledAddonComplete | None]] = HassKey(
"hassio_addons_info"
)
DATA_ADDONS_STATS: HassKey[dict[str, AddonsStats | None]] = HassKey(
"hassio_addons_stats"
)
DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
+223 -109
View File
@@ -3,17 +3,20 @@
import asyncio
from collections import defaultdict
from collections.abc import Awaitable
from copy import deepcopy
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Any, cast
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
AddonsStats,
AddonState,
CIFSMountResponse,
HomeAssistantInfo,
HomeAssistantStats,
HostInfo,
InstalledAddon,
InstalledAddonComplete,
NetworkInfo,
NFSMountResponse,
OSInfo,
@@ -21,10 +24,11 @@ from aiohasupervisor.models import (
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorStats,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
from homeassistant.const import ATTR_MANUFACTURER
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
@@ -34,15 +38,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
ATTR_ADDONS,
ATTR_AUTO_UPDATE,
ATTR_DATA,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_STARTUP,
ATTR_UPDATE_KEY,
ATTR_URL,
ATTR_VERSION,
ATTR_WS_EVENT,
CONTAINER_STATS,
CORE_CONTAINER,
@@ -53,12 +52,6 @@ from .const import (
DATA_CORE_STATS,
DATA_HOST_INFO,
DATA_INFO,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DATA_NETWORK_INFO,
DATA_OS_INFO,
@@ -86,6 +79,106 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
@dataclass
class HassioMainData:
"""Data class for HassioMainDataUpdateCoordinator."""
core: HomeAssistantInfo
supervisor: SupervisorInfo
host: HostInfo
mounts: dict[str, CIFSMountResponse | NFSMountResponse]
os: OSInfo | None
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict(),
"supervisor": self.supervisor.to_dict(),
"host": self.host.to_dict(),
"mounts": {name: mount.to_dict() for name, mount in self.mounts.items()},
"os": self.os.to_dict() if self.os is not None else None,
}
@dataclass
class AddonData:
"""Data for a single installed addon."""
addon: InstalledAddon
auto_update: bool
repository: str
@dataclass
class HassioAddonData:
"""Data class for HassioAddOnDataUpdateCoordinator."""
addons: dict[str, AddonData]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"addons": {
slug: {
"addon": addon_data.addon.to_dict(),
"auto_update": addon_data.auto_update,
"repository": addon_data.repository,
}
for slug, addon_data in self.addons.items()
},
}
@dataclass
class HassioStatsData:
"""Data class for HassioStatsDataUpdateCoordinator."""
core: HomeAssistantStats | None
supervisor: SupervisorStats | None
addons: dict[str, AddonsStats | None]
def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the data."""
return {
"core": self.core.to_dict() if self.core is not None else None,
"supervisor": (
self.supervisor.to_dict() if self.supervisor is not None else None
),
"addons": {
slug: stats.to_dict() if stats is not None else None
for slug, stats in self.addons.items()
},
}
def _installed_addon_from_complete(info: InstalledAddonComplete) -> InstalledAddon:
"""Build an InstalledAddon from an InstalledAddonComplete object.
InstalledAddonComplete contains a superset of InstalledAddon fields.
This helper extracts only the fields needed for InstalledAddon so fresh
data from an addon_info call can be stored in AddonData.addon.
"""
return InstalledAddon(
advanced=info.advanced,
available=info.available,
build=info.build,
description=info.description,
homeassistant=info.homeassistant,
icon=info.icon,
logo=info.logo,
name=info.name,
repository=info.repository,
slug=info.slug,
stage=info.stage,
update_available=info.update_available,
url=info.url,
version_latest=info.version_latest,
version=info.version,
detached=info.detached,
state=info.state,
)
@callback
def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
"""Return generic information from Supervisor.
@@ -151,7 +244,25 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | N
Async friendly.
"""
return hass.data.get(DATA_ADDONS_INFO)
addons_info: dict[str, InstalledAddonComplete | None] | None = hass.data.get(
DATA_ADDONS_INFO
)
if addons_info is None:
return None
# Converting these fields for compatibility as that is what was returned here.
# We'll leave it this way as long as these component APIs continue to return
# dictionaries. If/when we switch to using the aiohasupervisor models for everything
# internally and externally that will be dropped.
return {
slug: dict(
hassio_api=info.supervisor_api,
hassio_role=info.supervisor_role,
**info.to_dict(),
)
if info is not None
else None
for slug, info in addons_info.items()
}
@callback
@@ -170,7 +281,11 @@ def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
Async friendly.
"""
return hass.data.get(DATA_ADDONS_STATS) or {}
addons_stats: dict[str, AddonsStats | None] = hass.data.get(DATA_ADDONS_STATS) or {}
return {
slug: stats.to_dict() if stats is not None else None
for slug, stats in addons_stats.items()
}
@callback
@@ -179,7 +294,8 @@ def get_core_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
return hass.data.get(DATA_CORE_STATS) or {}
stats = hass.data.get(DATA_CORE_STATS)
return stats.to_dict() if stats is not None else {}
@callback
@@ -188,7 +304,8 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]:
Async friendly.
"""
return hass.data.get(DATA_SUPERVISOR_STATS) or {}
stats = hass.data.get(DATA_SUPERVISOR_STATS)
return stats.to_dict() if stats is not None else {}
@callback
@@ -222,19 +339,20 @@ def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
@callback
def async_register_addons_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]]
entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[AddonData]
) -> None:
"""Register addons in the device registry."""
for addon in addons:
for addon_data in addons:
addon = addon_data.addon
params = DeviceInfo(
identifiers={(DOMAIN, addon[ATTR_SLUG])},
identifiers={(DOMAIN, addon.slug)},
model=SupervisorEntityModel.ADDON,
sw_version=addon[ATTR_VERSION],
name=addon[ATTR_NAME],
sw_version=addon.version,
name=addon.name,
entry_type=dr.DeviceEntryType.SERVICE,
configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}",
configuration_url=f"homeassistant://hassio/addon/{addon.slug}",
)
if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL):
if manufacturer := addon_data.repository or addon.url:
params[ATTR_MANUFACTURER] = manufacturer
dev_reg.async_get_or_create(config_entry_id=entry_id, **params)
@@ -260,14 +378,14 @@ def async_register_mounts_in_dev_reg(
@callback
def async_register_os_in_dev_reg(
entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any]
entry_id: str, dev_reg: dr.DeviceRegistry, os_info: OSInfo
) -> None:
"""Register OS in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "OS")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.OS,
sw_version=os_dict[ATTR_VERSION],
sw_version=os_info.version,
name="Home Assistant Operating System",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -294,14 +412,14 @@ def async_register_host_in_dev_reg(
def async_register_core_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
core_dict: dict[str, Any],
core_info: HomeAssistantInfo,
) -> None:
"""Register OS in the device registry."""
"""Register core in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "core")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.CORE,
sw_version=core_dict[ATTR_VERSION],
sw_version=core_info.version,
name="Home Assistant Core",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -312,14 +430,14 @@ def async_register_core_in_dev_reg(
def async_register_supervisor_in_dev_reg(
entry_id: str,
dev_reg: dr.DeviceRegistry,
supervisor_dict: dict[str, Any],
supervisor_info: SupervisorInfo,
) -> None:
"""Register OS in the device registry."""
"""Register supervisor in the device registry."""
params = DeviceInfo(
identifiers={(DOMAIN, "supervisor")},
manufacturer="Home Assistant",
model=SupervisorEntityModel.SUPERVISOR,
sw_version=supervisor_dict[ATTR_VERSION],
sw_version=supervisor_info.version,
name="Home Assistant Supervisor",
entry_type=dr.DeviceEntryType.SERVICE,
)
@@ -336,7 +454,7 @@ def async_remove_devices_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]):
"""Class to retrieve Hass.io container stats."""
config_entry: ConfigEntry
@@ -358,18 +476,18 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
lambda: defaultdict(set)
)
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioStatsData:
"""Update stats data via library."""
try:
await self._fetch_stats()
except SupervisorError as err:
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = get_core_stats(self.hass)
new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass)
new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass)
return new_data
return HassioStatsData(
core=self.hass.data.get(DATA_CORE_STATS),
supervisor=self.hass.data.get(DATA_SUPERVISOR_STATS),
addons=self.hass.data.get(DATA_ADDONS_STATS) or {},
)
async def _fetch_stats(self) -> None:
"""Fetch container stats for subscribed entities."""
@@ -387,7 +505,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if updates:
api_results: list[ResponseData] = await asyncio.gather(*updates.values())
for key, result in zip(updates, api_results, strict=True):
data[key] = result.to_dict()
data[key] = result
# Fetch addon stats
addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or []
@@ -397,7 +515,9 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
}
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
addons_stats: dict[str, AddonsStats | None] = data.setdefault(
DATA_ADDONS_STATS, {}
)
# Clean up cache for stopped/removed addons
for slug in addons_stats.keys() - started_addons:
@@ -415,14 +535,14 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
addons_stats.update(addon_stats_results)
async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]:
async def _update_addon_stats(self, slug: str) -> tuple[str, AddonsStats | None]:
"""Update single addon stats."""
try:
stats = await self.supervisor_client.addons.addon_stats(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch stats for %s: %s", slug, err)
return (slug, None)
return (slug, stats.to_dict())
return (slug, stats)
@callback
def async_enable_container_updates(
@@ -445,7 +565,7 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return _remove
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]):
"""Class to retrieve Hass.io Add-on status."""
config_entry: ConfigEntry
@@ -476,7 +596,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.supervisor_client = get_supervisor_client(hass)
self.jobs = jobs
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioAddonData:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -487,7 +607,7 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Fetch addon info for all addons on first update, or only
# for addons with subscribed entities on subsequent updates.
addon_info_results = dict(
addon_info_results: dict[str, InstalledAddonComplete | None] = dict(
await asyncio.gather(
*[
self._update_addon_info(slug)
@@ -503,39 +623,35 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.hass.data[DATA_ADDONS_LIST] = installed_addons
# Update addon info cache in hass.data
addon_info_cache: dict[str, Any] = self.hass.data.setdefault(
DATA_ADDONS_INFO, {}
)
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
for slug in addon_info_cache.keys() - all_addons:
del addon_info_cache[slug]
addon_info_cache.update(addon_info_results)
# Build clean coordinator data
# Build repository name lookup from store data
store = self.hass.data.get(DATA_STORE)
if store:
repositories = {repo.slug: repo.name for repo in store.repositories}
else:
repositories = {}
repositories: dict[str, str] = (
{repo.slug: repo.name for repo in store.repositories} if store else {}
)
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
new_data: dict[str, Any] = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
**addon,
ATTR_AUTO_UPDATE: (addon_info_cache.get(slug) or {}).get(
ATTR_AUTO_UPDATE, False
),
ATTR_REPOSITORY: repositories.get(
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
),
}
for addon in addons_list_dicts
}
# Build clean coordinator data
new_addons: dict[str, AddonData] = {}
for addon in installed_addons:
addon_info = addon_info_cache.get(addon.slug)
auto_update = addon_info.auto_update if addon_info is not None else False
repo_slug = addon.repository
repository = repositories.get(repo_slug, repo_slug)
new_addons[addon.slug] = AddonData(
addon=addon,
auto_update=auto_update,
repository=repository,
)
new_data = HassioAddonData(addons=new_addons)
# If this is the initial refresh, register all addons
if is_first_update:
async_register_addons_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values()
self.entry_id, self.dev_reg, list(new_data.addons.values())
)
# Remove add-ons that are no longer installed from device registry
@@ -546,19 +662,16 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if device.model == SupervisorEntityModel.ADDON
}
if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]):
if stale_addons := supervisor_addon_devices - set(new_data.addons):
async_remove_devices_from_dev_reg(self.dev_reg, stale_addons)
# If there are new add-ons, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# create new devices and entities. We can return the new data because
# coordinator will be recreated.
if self.data and (
set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS])
):
if self.data and (set(new_data.addons) - set(self.data.addons)):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -569,18 +682,16 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except SupervisorNotFoundError:
return None
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
async def _update_addon_info(
self, slug: str
) -> tuple[str, InstalledAddonComplete | None]:
"""Return the info for an addon."""
try:
info = await self.supervisor_client.addons.addon_info(slug)
except SupervisorError as err:
_LOGGER.warning("Could not fetch info for %s: %s", slug, err)
return (slug, None)
# Translate to legacy hassio names for compatibility
info_dict = info.to_dict()
info_dict["hassio_api"] = info_dict.pop("supervisor_api")
info_dict["hassio_role"] = info_dict.pop("supervisor_role")
return (slug, info_dict)
return (slug, info)
@callback
def async_enable_addon_info_updates(
@@ -627,16 +738,26 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Force refresh of addon info data for a specific addon."""
try:
slug, info = await self._update_addon_info(addon_slug)
if info is not None and DATA_KEY_ADDONS in self.data:
if slug in self.data[DATA_KEY_ADDONS]:
data = deepcopy(self.data)
data[DATA_KEY_ADDONS][slug].update(info)
self.async_set_updated_data(data)
except SupervisorError as err:
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
return
if info is not None and self.data and slug in self.data.addons:
updated = AddonData(
addon=_installed_addon_from_complete(info),
auto_update=info.auto_update,
repository=self.data.addons[slug].repository,
)
self.async_set_updated_data(
HassioAddonData(addons={**self.data.addons, slug: updated})
)
# Update addon info cache in hass.data
addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {})
addon_info_cache[slug] = info
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
@@ -679,7 +800,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
):
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> HassioMainData:
"""Update data via library."""
is_first_update = not self.data
client = self.supervisor_client
@@ -722,13 +843,13 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Build clean coordinator data
new_data: dict[str, Any] = {}
new_data[DATA_KEY_CORE] = core_info.to_dict()
new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict()
new_data[DATA_KEY_HOST] = host_info.to_dict()
new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts}
if self.is_hass_os:
new_data[DATA_KEY_OS] = os_info.to_dict()
new_data = HassioMainData(
core=core_info,
supervisor=supervisor_info,
host=host_info,
mounts={mount.name: mount for mount in mounts_info.mounts},
os=os_info if self.is_hass_os else None,
)
# Update hass.data for legacy accessor functions
self.hass.data[DATA_INFO] = info
@@ -742,19 +863,15 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# If this is the initial refresh, register all main components
if is_first_update:
async_register_mounts_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values()
)
async_register_core_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE]
self.entry_id, self.dev_reg, list(new_data.mounts.values())
)
async_register_core_in_dev_reg(self.entry_id, self.dev_reg, new_data.core)
async_register_supervisor_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR]
self.entry_id, self.dev_reg, new_data.supervisor
)
async_register_host_in_dev_reg(self.entry_id, self.dev_reg)
if self.is_hass_os:
async_register_os_in_dev_reg(
self.entry_id, self.dev_reg, new_data[DATA_KEY_OS]
)
async_register_os_in_dev_reg(self.entry_id, self.dev_reg, os_info)
# Remove mounts that no longer exists from device registry
supervisor_mount_devices = {
@@ -764,7 +881,7 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if device.model == SupervisorEntityModel.MOUNT
}
if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]):
if stale_mounts := supervisor_mount_devices - set(new_data.mounts):
async_remove_devices_from_dev_reg(
self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts}
)
@@ -776,15 +893,12 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.dev_reg.async_remove_device(dev.id)
# If there are new mounts, we should reload the config entry so we can
# create new devices and entities. We can return an empty dict because
# create new devices and entities. We can return the new data because
# coordinator will be recreated.
if self.data and (
set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {}))
):
if self.data and (set(new_data.mounts) - set(self.data.mounts)):
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry_id)
)
return {}
return new_data
@@ -56,8 +56,8 @@ async def async_get_config_entry_diagnostics(
devices.append({"device": asdict(device), "entities": entities})
return {
"coordinator_data": coordinator.data,
"addons_coordinator_data": addons_coordinator.data,
"stats_coordinator_data": stats_coordinator.data,
"coordinator_data": coordinator.data.to_dict(),
"addons_coordinator_data": addons_coordinator.data.to_dict(),
"stats_coordinator_data": stats_coordinator.data.to_dict(),
"devices": devices,
}
+54 -74
View File
@@ -1,27 +1,20 @@
"""Base for Hass.io entities."""
from typing import Any
from collections.abc import Callable
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
from aiohasupervisor.models import CIFSMountResponse, HostInfo, NFSMountResponse, OSInfo
from aiohasupervisor.models.base import ContainerStats
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_SLUG,
CONTAINER_STATS,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_MOUNTS,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
DOMAIN,
)
from .const import CONTAINER_STATS, DOMAIN
from .coordinator import (
AddonData,
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsData,
HassioStatsDataUpdateCoordinator,
)
@@ -37,7 +30,7 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
entity_description: EntityDescription,
*,
container_id: str,
data_key: str,
stats_fn: Callable[[HassioStatsData], ContainerStats | None],
device_id: str,
unique_id_prefix: str,
) -> None:
@@ -45,27 +38,25 @@ class HassioStatsEntity(CoordinatorEntity[HassioStatsDataUpdateCoordinator]):
super().__init__(coordinator)
self.entity_description = entity_description
self._container_id = container_id
self._data_key = data_key
self._stats_fn = stats_fn
self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
@property
def _stats(self) -> ContainerStats | None:
"""Return the stats object for this entity's container."""
return self._stats_fn(self.coordinator.data)
@property
def stats(self) -> ContainerStats:
"""Return the stats object, asserting it is available."""
assert self._stats is not None
return self._stats
@property
def available(self) -> bool:
"""Return True if entity is available."""
if self._data_key == DATA_KEY_ADDONS:
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in (
self.coordinator.data[DATA_KEY_ADDONS].get(self._container_id) or {}
)
)
return (
super().available
and self._data_key in self.coordinator.data
and self.entity_description.key in self.coordinator.data[self._data_key]
)
return super().available and self._stats is not None
async def async_added_to_hass(self) -> None:
"""Subscribe to stats updates."""
@@ -92,24 +83,31 @@ class HassioAddonEntity(CoordinatorEntity[HassioAddOnDataUpdateCoordinator]):
self,
coordinator: HassioAddOnDataUpdateCoordinator,
entity_description: EntityDescription,
addon: dict[str, Any],
addon: AddonData,
) -> None:
"""Initialize base entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._addon_slug = addon[ATTR_SLUG]
self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])})
self._addon_slug = addon.addon.slug
self._attr_unique_id = f"{addon.addon.slug}_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon.addon.slug)})
@property
def addon_slug(self) -> str:
"""Return the add-on slug."""
return self._addon_slug
@property
def addon_data(self) -> AddonData:
"""Return the add-on data, asserting it is available."""
data = self.coordinator.data
assert self._addon_slug in data.addons
return data.addons[self._addon_slug]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_ADDONS in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
)
return super().available and self._addon_slug in self.coordinator.data.addons
async def async_added_to_hass(self) -> None:
"""Subscribe to addon info updates."""
@@ -140,11 +138,13 @@ class HassioOSEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_OS in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_OS]
)
return super().available and self.coordinator.data.os is not None
@property
def os(self) -> OSInfo:
"""Return the OS info object, asserting it is available."""
assert self.coordinator.data.os is not None
return self.coordinator.data.os
class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -164,13 +164,10 @@ class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_HOST in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST]
)
def host(self) -> HostInfo:
"""Return the host info, asserting it is available."""
assert self.coordinator.data.host is not None
return self.coordinator.data.host
class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
@@ -189,16 +186,6 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator])
self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_SUPERVISOR in self.coordinator.data
and self.entity_description.key
in self.coordinator.data[DATA_KEY_SUPERVISOR]
)
class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Core."""
@@ -216,15 +203,6 @@ class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
self._attr_unique_id = f"home_assistant_core_{entity_description.key}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")})
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and DATA_KEY_CORE in self.coordinator.data
and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE]
)
class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
"""Base Entity for Mount."""
@@ -248,10 +226,12 @@ class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]):
)
self._mount = mount
@property
def mount_name(self) -> str:
"""Return the mount name."""
return self._mount.name
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS]
)
return super().available and self.mount_name in self.coordinator.data.mounts
+106 -44
View File
@@ -1,5 +1,10 @@
"""Sensor platform for Hass.io addons."""
from collections.abc import Callable
from dataclasses import dataclass
from aiohasupervisor.models.base import ContainerStats
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -15,19 +20,12 @@ from .const import (
ADDONS_COORDINATOR,
ATTR_CPU_PERCENT,
ATTR_MEMORY_PERCENT,
ATTR_SLUG,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CORE_CONTAINER,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_HOST,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
STATS_COORDINATOR,
SUPERVISOR_CONTAINER,
)
from .coordinator import HassioStatsData
from .entity import (
HassioAddonEntity,
HassioHostEntity,
@@ -35,74 +33,125 @@ from .entity import (
HassioStatsEntity,
)
COMMON_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class HassioAddonSensorEntityDescription(SensorEntityDescription):
"""Hass.io add-on sensor entity description."""
value_fn: Callable[[HassioAddonSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioStatsSensorEntityDescription(SensorEntityDescription):
"""Hass.io stats sensor entity description."""
value_fn: Callable[[HassioStatsSensor], float]
@dataclass(frozen=True, kw_only=True)
class HassioOSSensorEntityDescription(SensorEntityDescription):
"""Hass.io OS sensor entity description."""
value_fn: Callable[[HassioOSSensor], str | None]
@dataclass(frozen=True, kw_only=True)
class HassioHostSensorEntityDescription(SensorEntityDescription):
"""Hass.io host sensor entity description."""
value_fn: Callable[[HostSensor], str | float | None]
ADDON_ENTITY_DESCRIPTIONS = (
HassioAddonSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_VERSION,
key="version",
translation_key="version",
value_fn=lambda entity: entity.addon_data.addon.version,
),
SensorEntityDescription(
HassioAddonSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_VERSION_LATEST,
key="version_latest",
translation_key="version_latest",
value_fn=lambda entity: entity.addon_data.addon.version_latest,
),
)
STATS_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
HassioStatsSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_CPU_PERCENT,
translation_key="cpu_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.cpu_percent,
),
SensorEntityDescription(
HassioStatsSensorEntityDescription(
entity_registry_enabled_default=False,
key=ATTR_MEMORY_PERCENT,
translation_key="memory_percent",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda entity: entity.stats.memory_percent,
),
)
OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS
OS_ENTITY_DESCRIPTIONS = (
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version",
translation_key="version",
value_fn=lambda entity: entity.os.version,
),
HassioOSSensorEntityDescription(
entity_registry_enabled_default=False,
key="version_latest",
translation_key="version_latest",
value_fn=lambda entity: entity.os.version_latest,
),
)
HOST_ENTITY_DESCRIPTIONS = (
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="agent_version",
translation_key="agent_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.agent_version,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="apparmor_version",
translation_key="apparmor_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.apparmor_version,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_total",
translation_key="disk_total",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_total,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_used",
translation_key="disk_used",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_used,
),
SensorEntityDescription(
HassioHostSensorEntityDescription(
entity_registry_enabled_default=False,
key="disk_free",
translation_key="disk_free",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda entity: entity.host.disk_free,
),
)
@@ -126,21 +175,32 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=entity_description,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for entity_description in COMMON_ENTITY_DESCRIPTIONS
for addon in addons_coordinator.data.addons.values()
for entity_description in ADDON_ENTITY_DESCRIPTIONS
)
# Add-on stats sensors (cpu_percent, memory_percent)
def stats_fn_factory(
addon_slug: str,
) -> Callable[[HassioStatsData], ContainerStats | None]:
"""Return a stats_fn for the given add-on slug."""
def stats_fn(data: HassioStatsData) -> ContainerStats | None:
"""Return the stats for the given add-on."""
return data.addons.get(addon_slug)
return stats_fn
entities.extend(
HassioStatsSensor(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=addon[ATTR_SLUG],
data_key=DATA_KEY_ADDONS,
device_id=addon[ATTR_SLUG],
unique_id_prefix=addon[ATTR_SLUG],
container_id=addon.addon.slug,
stats_fn=stats_fn_factory(addon.addon.slug),
device_id=addon.addon.slug,
unique_id_prefix=addon.addon.slug,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.addons.values()
for entity_description in STATS_ENTITY_DESCRIPTIONS
)
@@ -150,7 +210,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=CORE_CONTAINER,
data_key=DATA_KEY_CORE,
stats_fn=lambda data: data.core,
device_id="core",
unique_id_prefix="home_assistant_core",
)
@@ -163,7 +223,7 @@ async def async_setup_entry(
coordinator=stats_coordinator,
entity_description=entity_description,
container_id=SUPERVISOR_CONTAINER,
data_key=DATA_KEY_SUPERVISOR,
stats_fn=lambda data: data.supervisor,
device_id="supervisor",
unique_id_prefix="home_assistant_supervisor",
)
@@ -195,40 +255,42 @@ async def async_setup_entry(
class HassioAddonSensor(HassioAddonEntity, SensorEntity):
"""Sensor to track a Hass.io add-on attribute."""
entity_description: HassioAddonSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][
self.entity_description.key
]
return self.entity_description.value_fn(self)
class HassioStatsSensor(HassioStatsEntity, SensorEntity):
"""Sensor to track container stats."""
entity_description: HassioStatsSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> float:
"""Return native value of entity."""
if self._data_key == DATA_KEY_ADDONS:
return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][
self.entity_description.key
]
return self.coordinator.data[self._data_key][self.entity_description.key]
return self.entity_description.value_fn(self)
class HassioOSSensor(HassioOSEntity, SensorEntity):
"""Sensor to track a Hass.io OS attribute."""
entity_description: HassioOSSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_OS][self.entity_description.key]
return self.entity_description.value_fn(self)
class HostSensor(HassioHostEntity, SensorEntity):
"""Sensor to track a host attribute."""
entity_description: HassioHostSensorEntityDescription
@property
def native_value(self) -> str:
def native_value(self) -> str | float | None:
"""Return native value of entity."""
return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key]
return self.entity_description.value_fn(self)
+10 -10
View File
@@ -4,15 +4,15 @@ import logging
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonState
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .const import ADDONS_COORDINATOR
from .entity import HassioAddonEntity
from .handler import get_supervisor_client
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
ENTITY_DESCRIPTION = SwitchEntityDescription(
key=ATTR_STATE,
key="state",
name=None,
icon="mdi:puzzle",
entity_registry_enabled_default=False,
@@ -41,7 +41,7 @@ async def async_setup_entry(
coordinator=coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in coordinator.data[DATA_KEY_ADDONS].values()
for addon in coordinator.data.addons.values()
)
@@ -49,19 +49,19 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
"""Switch for Hass.io add-ons."""
@property
def is_on(self) -> bool | None:
def is_on(self) -> bool:
"""Return true if the add-on is on."""
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
state = addon_data.get(self.entity_description.key)
return state == ATTR_STARTED
return (
self.coordinator.data.addons[self._addon_slug].addon.state
== AddonState.STARTED
)
@property
def entity_picture(self) -> str | None:
"""Return the icon of the add-on if any."""
if not self.available:
return None
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
if addon_data.get(ATTR_ICON):
if self.coordinator.data.addons[self._addon_slug].addon.icon:
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
+24 -32
View File
@@ -13,22 +13,12 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ADDONS_COORDINATOR,
ATTR_AUTO_UPDATE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
DATA_KEY_ADDONS,
DATA_KEY_CORE,
DATA_KEY_OS,
DATA_KEY_SUPERVISOR,
MAIN_COORDINATOR,
)
from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR
from .coordinator import AddonData
from .entity import (
HassioAddonEntity,
HassioCoreEntity,
@@ -78,7 +68,7 @@ async def async_setup_entry(
coordinator=addons_coordinator,
entity_description=ENTITY_DESCRIPTION,
)
for addon in addons_coordinator.data[DATA_KEY_ADDONS].values()
for addon in addons_coordinator.data.addons.values()
)
async_add_entities(entities)
@@ -108,29 +98,29 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
_version_before_update: str | None = None
@property
def _addon_data(self) -> dict:
def _addon_data(self) -> AddonData:
"""Return the add-on data."""
return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug]
return self.coordinator.data.addons[self._addon_slug]
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for the add-on."""
return self._addon_data[ATTR_AUTO_UPDATE]
return self._addon_data.auto_update
@property
def title(self) -> str | None:
"""Return the title of the update."""
return self._addon_data[ATTR_NAME]
return self._addon_data.addon.name
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self._addon_data[ATTR_VERSION_LATEST]
return self._addon_data.addon.version_latest
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self._addon_data[ATTR_VERSION]
return self._addon_data.addon.version
@property
def in_progress(self) -> bool | None:
@@ -144,7 +134,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Return the icon of the add-on if any."""
if not self.available:
return None
if self._addon_data[ATTR_ICON]:
if self._addon_data.addon.icon:
return f"/api/hassio/addons/{self._addon_slug}/icon"
return None
@@ -236,14 +226,16 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
_attr_title = "Home Assistant Operating System"
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST]
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version_latest
@property
def installed_version(self) -> str:
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION]
assert self.coordinator.data.os is not None
return self.coordinator.data.os.version
@property
def entity_picture(self) -> str | None:
@@ -293,19 +285,19 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
return self._attr_in_progress
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST]
return self.coordinator.data.supervisor.version_latest
@property
def installed_version(self) -> str:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION]
return self.coordinator.data.supervisor.version
@property
def auto_update(self) -> bool:
"""Return true if auto-update is enabled for supervisor."""
return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_AUTO_UPDATE]
return self.coordinator.data.supervisor.auto_update
@property
def release_url(self) -> str | None:
@@ -389,14 +381,14 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
_attr_title = "Home Assistant Core"
@property
def latest_version(self) -> str:
def latest_version(self) -> str | None:
"""Return the latest version."""
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST]
return self.coordinator.data.core.version_latest
@property
def installed_version(self) -> str:
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION]
return self.coordinator.data.core.version
@property
def entity_picture(self) -> str | None:
+11 -5
View File
@@ -44,14 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
except HiveReauthRequired as err:
raise ConfigEntryAuthFailed from err
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, devices["parent"][0]["device_id"])},
name=devices["parent"][0]["hiveName"],
model=devices["parent"][0]["deviceData"]["model"],
sw_version=devices["parent"][0]["deviceData"]["version"],
manufacturer=devices["parent"][0]["deviceData"]["manufacturer"],
identifiers={(DOMAIN, hub_data["device_id"])},
connections=connections,
name=hub_data["hiveName"],
model=hub_data["deviceData"]["model"],
sw_version=hub_data["deviceData"]["version"],
manufacturer=hub_data["deviceData"]["manufacturer"],
)
await hass.config_entries.async_forward_entry_setups(
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.95", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.9.0"]
"requirements": ["homematicip==2.11.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,6 +46,20 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -79,10 +93,7 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
"is_target_humidity": IsTargetHumidityCondition,
}
+26 -3
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,8 +31,31 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
"is_value": HumidityCondition,
}
+43 -9
View File
@@ -13,12 +13,13 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -36,13 +37,46 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.4"]
"requirements": ["aioautomower==2.7.5"]
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
}
+10 -19
View File
@@ -76,14 +76,12 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -93,18 +91,15 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -151,8 +146,6 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -250,8 +243,6 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["indevolt-api==1.7.1"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==2.0.0"]
"requirements": ["infrared-protocols==2.1.0"]
}
@@ -2,7 +2,7 @@
"domain": "insteon",
"name": "Insteon",
"after_dependencies": ["panel_custom"],
"codeowners": ["@teharris1"],
"codeowners": ["@teharris1", "@ssyrell"],
"config_flow": true,
"dependencies": ["http", "usb", "websocket_api"],
"dhcp": [
@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.6.1"
"insteon-frontend-home-assistant==0.6.2"
],
"single_config_entry": true,
"usb": [
@@ -77,10 +77,9 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
+1 -1
View File
@@ -12,7 +12,7 @@
"quality_scale": "platinum",
"requirements": [
"xknx==3.15.0",
"xknxproject==3.8.2",
"xknxproject==3.9.0",
"knx-frontend==2026.4.30.60856"
],
"single_config_entry": true
@@ -0,0 +1,90 @@
{
"entity": {
"button": {
"back": {
"default": "mdi:keyboard-backspace"
},
"down": {
"default": "mdi:arrow-down"
},
"exit": {
"default": "mdi:exit-to-app"
},
"guide": {
"default": "mdi:television-guide"
},
"hdmi_1": {
"default": "mdi:video-input-hdmi"
},
"hdmi_2": {
"default": "mdi:video-input-hdmi"
},
"hdmi_3": {
"default": "mdi:video-input-hdmi"
},
"hdmi_4": {
"default": "mdi:video-input-hdmi"
},
"home": {
"default": "mdi:home"
},
"info": {
"default": "mdi:information-outline"
},
"input": {
"default": "mdi:import"
},
"left": {
"default": "mdi:arrow-left"
},
"menu": {
"default": "mdi:menu"
},
"num_0": {
"default": "mdi:numeric-0"
},
"num_1": {
"default": "mdi:numeric-1"
},
"num_2": {
"default": "mdi:numeric-2"
},
"num_3": {
"default": "mdi:numeric-3"
},
"num_4": {
"default": "mdi:numeric-4"
},
"num_5": {
"default": "mdi:numeric-5"
},
"num_6": {
"default": "mdi:numeric-6"
},
"num_7": {
"default": "mdi:numeric-7"
},
"num_8": {
"default": "mdi:numeric-8"
},
"num_9": {
"default": "mdi:numeric-9"
},
"ok": {
"default": "mdi:check"
},
"power_off": {
"default": "mdi:power-off"
},
"power_on": {
"default": "mdi:power-on"
},
"right": {
"default": "mdi:arrow-right"
},
"up": {
"default": "mdi:arrow-up"
}
}
}
}
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from pylitterbot import LitterRobot, LitterRobot4, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -30,8 +30,11 @@ class RobotBinarySensorEntityDescription(
is_on_fn: Callable[[_WhiskerEntityT], bool]
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
BINARY_SENSOR_MAP: dict[
type[Robot] | tuple[type[Robot], ...],
tuple[RobotBinarySensorEntityDescription, ...],
] = {
LitterRobot: (
RobotBinarySensorEntityDescription[LitterRobot](
key="sleeping",
translation_key="sleeping",
@@ -56,14 +59,14 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
is_on_fn=lambda robot: not robot.is_hopper_removed,
),
),
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot](
(FeederRobot, LitterRobot3, LitterRobot4): (
RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4](
key="power_status",
translation_key="power_status",
device_class=BinarySensorDeviceClass.PLUG,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
is_on_fn=lambda robot: robot.power_status == "AC",
is_on_fn=lambda robot: robot.power_type == "AC",
),
),
}
@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.3.2"]
"requirements": ["pylitterbot==2025.4.0"]
}
+8 -2
View File
@@ -251,8 +251,10 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -302,8 +304,12 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
@@ -1,11 +1,108 @@
"""Provides conditions for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -17,18 +114,10 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,22 +1,51 @@
.condition_common: &condition_common
target:
target: &condition_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,5 +1,8 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -14,6 +17,12 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -123,6 +132,9 @@
}
},
"triggers": {
"muted": {
"trigger": "mdi:volume-mute"
},
"paused_playing": {
"trigger": "mdi:pause"
},
@@ -137,6 +149,15 @@
},
"turned_on": {
"trigger": "mdi:power"
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,10 +2,24 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -65,6 +79,33 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -437,6 +478,18 @@
},
"title": "Media player",
"triggers": {
"muted": {
"description": "Triggers after one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player muted"
},
"paused_playing": {
"description": "Triggers after one or more media players pause playing.",
"fields": {
@@ -496,6 +549,42 @@
}
},
"name": "Media player turned on"
},
"unmuted": {
"description": "Triggers after one or more media players are unmuted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -1,12 +1,121 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
)
from . import MediaPlayerState
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without volume attributes cannot be muted, so they are
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return super()._should_include(state) and self._has_volume_attributes(state)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the muted-state changed."""
if not self._has_volume_attributes(to_state):
return False
return self.is_muted(from_state) != self.is_muted(to_state)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
return self.is_muted(state) is self._target_muted
class MediaPlayerMutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player muted triggers."""
_target_muted = True
class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player unmuted triggers."""
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,22 +1,62 @@
.trigger_common: &trigger_common
target:
target: &trigger_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
automation_behavior:
mode: trigger
for:
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
+1
View File
@@ -479,6 +479,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
down_filled_items = 129
cottons_eco = 133
quick_power_wash = 146, 10031
quick_intense = 177
eco_40_60 = 190, 10007
bed_linen = 10047
easy_care = 10016
+10 -13
View File
@@ -5,30 +5,31 @@ from datetime import timedelta
from mill import Mill
from mill_local import Mill as MillLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator
from .coordinator import (
MillConfigEntry,
MillDataUpdateCoordinator,
MillHistoricDataUpdateCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Set up the Mill heater."""
hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}})
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_connection = MillLocal(
entry.data[CONF_IP_ADDRESS],
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=15)
key = entry.data[CONF_IP_ADDRESS]
conn_type = LOCAL
else:
mill_data_connection = Mill(
entry.data[CONF_USERNAME],
@@ -36,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=30)
key = entry.data[CONF_USERNAME]
conn_type = CLOUD
historic_data_coordinator = MillHistoricDataUpdateCoordinator(
hass,
@@ -56,14 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await data_coordinator.async_config_entry_first_refresh()
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
hass.data[DOMAIN][conn_type][key] = data_coordinator
entry.runtime_data = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+5 -15
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from typing import Any
@@ -14,14 +13,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_IP_ADDRESS,
CONF_USERNAME,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -33,7 +25,6 @@ from .const import (
ATTR_COMFORT_TEMP,
ATTR_ROOM_NAME,
ATTR_SLEEP_TEMP,
CLOUD,
CONNECTION_TYPE,
DOMAIN,
LOCAL,
@@ -42,7 +33,7 @@ from .const import (
MIN_TEMP,
SERVICE_SET_ROOM_TEMP,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
SET_ROOM_TEMP_SCHEMA = vol.Schema(
@@ -57,17 +48,16 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill climate."""
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
async_add_entities([LocalMillHeater(mill_data_coordinator)])
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillHeater(mill_data_coordinator, mill_device)
for mill_device in mill_data_coordinator.data.values()
@@ -57,6 +57,9 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator):
)
type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator]
class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Mill historic data."""
+5 -10
View File
@@ -3,28 +3,23 @@
from mill import Heater, MillDevice
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfPower
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CLOUD, CONNECTION_TYPE, DOMAIN
from .coordinator import MillDataUpdateCoordinator
from .const import CLOUD, CONNECTION_TYPE
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill Number."""
if entry.data.get(CONNECTION_TYPE) == CLOUD:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][
entry.data[CONF_USERNAME]
]
mill_data_coordinator = entry.runtime_data
async_add_entities(
MillNumber(mill_data_coordinator, mill_device)
+4 -12
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import mill
@@ -9,12 +8,9 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONF_IP_ADDRESS,
CONF_USERNAME,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
@@ -29,11 +25,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
BATTERY,
CLOUD,
CONNECTION_TYPE,
CONSUMPTION_TODAY,
CONSUMPTION_YEAR,
DOMAIN,
ECO2,
HUMIDITY,
LOCAL,
@@ -41,7 +35,7 @@ from .const import (
TEMPERATURE,
TVOC,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -146,13 +140,13 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill sensor."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
async_add_entities(
LocalMillSensor(
mill_data_coordinator,
@@ -162,8 +156,6 @@ async def async_setup_entry(
)
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillSensor(
mill_data_coordinator,
@@ -0,0 +1,95 @@
"""Mitsubishi Comfort integration for Home Assistant."""
import asyncio
import logging
from mitsubishi_comfort import (
DeviceInfo,
IndoorUnit,
KumoStation,
MitsubishiCloudAccount,
)
from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=info.address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
connect_timeout=DEFAULT_CONNECT_TIMEOUT,
response_timeout=DEFAULT_RESPONSE_TIMEOUT,
session=session,
)
async def async_setup_entry(
hass: HomeAssistant, entry: MitsubishiComfortConfigEntry
) -> bool:
"""Set up Mitsubishi Comfort from a config entry."""
session = async_get_clientsession(hass)
account = MitsubishiCloudAccount(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
)
try:
await account.login()
devices = await account.discover_devices()
except AuthenticationError as err:
raise ConfigEntryError("Mitsubishi cloud authentication failed") from err
except DeviceConnectionError as err:
raise ConfigEntryNotReady("Cannot reach Mitsubishi cloud") from err
if not devices:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_devices",
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
continue
device = _make_device(info, serial, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
await asyncio.gather(
*(c.async_config_entry_first_refresh() for c in coordinators.values())
)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MitsubishiComfortConfigEntry
) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await asyncio.gather(
*(c.device.close() for c in entry.runtime_data.values()),
return_exceptions=True,
)
return unload_ok
@@ -0,0 +1,287 @@
"""Climate entity for Mitsubishi Comfort integration."""
from typing import Any
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
from .entity import MitsubishiComfortEntity
_MODE_TO_HVAC: dict[str, HVACMode] = {
"off": HVACMode.OFF,
"cool": HVACMode.COOL,
"heat": HVACMode.HEAT,
"dry": HVACMode.DRY,
"vent": HVACMode.FAN_ONLY,
"auto": HVACMode.HEAT_COOL,
"autoCool": HVACMode.HEAT_COOL,
"autoHeat": HVACMode.HEAT_COOL,
}
_HVAC_TO_MODE: dict[HVACMode, Mode] = {
HVACMode.OFF: Mode.OFF,
HVACMode.COOL: Mode.COOL,
HVACMode.HEAT: Mode.HEAT,
HVACMode.DRY: Mode.DRY,
HVACMode.FAN_ONLY: Mode.FAN,
HVACMode.HEAT_COOL: Mode.AUTO,
}
_LIB_MODE_TO_HVAC: dict[Mode, HVACMode] = {v: k for k, v in _HVAC_TO_MODE.items()}
_MODE_TO_ACTION: dict[str, HVACAction] = {
"off": HVACAction.OFF,
"cool": HVACAction.COOLING,
"heat": HVACAction.HEATING,
"dry": HVACAction.DRYING,
"vent": HVACAction.FAN,
"auto": HVACAction.IDLE,
"autoCool": HVACAction.COOLING,
"autoHeat": HVACAction.HEATING,
}
_FAN_SPEED_MAP: dict[str, FanSpeed] = {s.value: s for s in FanSpeed}
_VANE_DIR_MAP: dict[str, VaneDirection] = {d.value: d for d in VaneDirection}
_OPT_MODE = "mode"
_OPT_COOL_SETPOINT = "cool_setpoint"
_OPT_HEAT_SETPOINT = "heat_setpoint"
_OPT_FAN_SPEED = "fan_speed"
_OPT_VANE_DIRECTION = "vane_direction"
async def async_setup_entry(
hass: HomeAssistant,
entry: MitsubishiComfortConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Mitsubishi Comfort climate entities."""
coordinators = entry.runtime_data
async_add_entities(
MitsubishiComfortClimate(coordinator)
for coordinator in coordinators.values()
if isinstance(coordinator.device, IndoorUnit)
)
class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity):
"""Climate entity for a Mitsubishi indoor unit."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = self._device.serial
self._optimistic: dict[str, Any] = {}
def _handle_coordinator_update(self) -> None:
"""Clear optimistic state when real data arrives from device."""
self._optimistic.clear()
super()._handle_coordinator_update()
@property
def _effective_mode(self) -> str | None:
return self._optimistic.get(_OPT_MODE, self._device.status.mode)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
mode = self._effective_mode
return _MODE_TO_HVAC.get(mode) if mode else None
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
mode = self._effective_mode
if mode and self._device.status.standby:
return HVACAction.IDLE
return _MODE_TO_ACTION.get(mode) if mode else None
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
return [
_LIB_MODE_TO_HVAC[m]
for m in self._device.supported_modes
if m in _LIB_MODE_TO_HVAC
]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.status.room_temperature
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""
return self._device.status.current_humidity
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
mode = self._effective_mode
if mode in ("cool", "autoCool"):
return self._optimistic.get(
_OPT_COOL_SETPOINT, self._device.status.cool_setpoint
)
if mode in ("heat", "autoHeat"):
return self._optimistic.get(
_OPT_HEAT_SETPOINT, self._device.status.heat_setpoint
)
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the upper bound target temperature."""
if self._effective_mode in ("auto", "autoCool", "autoHeat"):
return self._optimistic.get(
_OPT_COOL_SETPOINT, self._device.status.cool_setpoint
)
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lower bound target temperature."""
if self._effective_mode in ("auto", "autoCool", "autoHeat"):
return self._optimistic.get(
_OPT_HEAT_SETPOINT, self._device.status.heat_setpoint
)
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return self._optimistic.get(_OPT_FAN_SPEED, self._device.status.fan_speed)
@property
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
return [s.value for s in self._device.supported_fan_speeds]
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
return self._optimistic.get(
_OPT_VANE_DIRECTION, self._device.status.vane_direction
)
@property
def swing_modes(self) -> list[str]:
"""Return the list of available swing modes."""
return [d.value for d in self._device.supported_vane_directions]
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
if self._effective_mode in ("heat", "autoHeat"):
if self._device.status.min_heat_setpoint is not None:
return self._device.status.min_heat_setpoint
if self._device.status.min_cool_setpoint is not None:
return self._device.status.min_cool_setpoint
return super().min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
if self._effective_mode in ("heat", "autoHeat"):
if self._device.status.max_heat_setpoint is not None:
return self._device.status.max_heat_setpoint
if self._device.status.max_cool_setpoint is not None:
return self._device.status.max_cool_setpoint
return super().max_temp
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_OFF
)
if Mode.AUTO in self._device.supported_modes:
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
if self._device.supported_vane_directions:
features |= ClimateEntityFeature.SWING_MODE
return features
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
lib_mode = _HVAC_TO_MODE.get(hvac_mode)
if lib_mode is None:
return
result = await self._device.set_mode(lib_mode)
if result.success:
self._optimistic[_OPT_MODE] = result.value
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
mode = self._effective_mode
wrote = False
if ATTR_TARGET_TEMP_HIGH in kwargs:
result = await self._device.set_cool_setpoint(kwargs[ATTR_TARGET_TEMP_HIGH])
if result.success:
self._optimistic[_OPT_COOL_SETPOINT] = result.value
wrote = True
if ATTR_TARGET_TEMP_LOW in kwargs:
result = await self._device.set_heat_setpoint(kwargs[ATTR_TARGET_TEMP_LOW])
if result.success:
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
wrote = True
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is not None:
if mode in ("cool", "autoCool"):
result = await self._device.set_cool_setpoint(temp)
if result.success:
self._optimistic[_OPT_COOL_SETPOINT] = result.value
wrote = True
elif mode in ("heat", "autoHeat"):
result = await self._device.set_heat_setpoint(temp)
if result.success:
self._optimistic[_OPT_HEAT_SETPOINT] = result.value
wrote = True
if wrote:
self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
speed = _FAN_SPEED_MAP.get(fan_mode)
if speed is None:
return
result = await self._device.set_fan_speed(speed)
if result.success:
self._optimistic[_OPT_FAN_SPEED] = result.value
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the swing mode."""
direction = _VANE_DIR_MAP.get(swing_mode)
if direction is None:
return
result = await self._device.set_vane_direction(direction)
if result.success:
self._optimistic[_OPT_VANE_DIRECTION] = result.value
self.async_write_ha_state()
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -0,0 +1,73 @@
"""Config flow for Mitsubishi Comfort integration."""
import logging
from typing import Any
from mitsubishi_comfort import MitsubishiCloudAccount
from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Mitsubishi Comfort."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user setup step."""
errors: dict[str, str] = {}
if user_input is not None:
account = MitsubishiCloudAccount(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
devices: dict = {}
try:
await account.login()
devices = await account.discover_devices()
except AuthenticationError:
errors["base"] = "invalid_auth"
except DeviceConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during setup")
errors["base"] = "unknown"
if not errors:
await self.async_set_unique_id(account.user_id)
self._abort_if_unique_id_configured()
if not devices:
errors["base"] = "no_devices"
else:
return self.async_create_entry(
title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})",
data={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
@@ -0,0 +1,12 @@
"""Constants for the Mitsubishi Comfort integration."""
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -0,0 +1,56 @@
"""DataUpdateCoordinator for Mitsubishi Comfort devices."""
import logging
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MitsubishiComfortConfigEntry = ConfigEntry[dict[str, MitsubishiComfortCoordinator]]
class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStation]):
"""Coordinator to poll a single Mitsubishi device."""
def __init__(
self,
hass: HomeAssistant,
entry: MitsubishiComfortConfigEntry,
device: IndoorUnit | KumoStation,
mac: str,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"mitsubishi_comfort_{device.serial}",
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.device = device
self.mac = mac
self.data = device
async def _async_update_data(self) -> IndoorUnit | KumoStation:
"""Poll the device and return it."""
try:
success = await self.device.update_status()
except Exception as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"device_name": self.device.name},
) from err
if not success:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"device_name": self.device.name},
)
return self.device
@@ -0,0 +1,34 @@
"""Base entity for Mitsubishi Comfort integration."""
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MitsubishiComfortCoordinator
class MitsubishiComfortEntity(CoordinatorEntity[MitsubishiComfortCoordinator]):
"""Base class for all Mitsubishi Comfort entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
device = coordinator.device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.serial)},
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
name=device.name,
manufacturer="Mitsubishi",
serial_number=device.serial,
sw_version=device.status.firmware_version,
hw_version=device.status.hardware_version,
)
@property
def _device(self) -> IndoorUnit | KumoStation:
"""Return the underlying device from coordinator data."""
return self.coordinator.data
@@ -0,0 +1,11 @@
{
"domain": "mitsubishi_comfort",
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
}
@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No service actions registered.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No service actions.
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
config-entry-unloading: done
log-when-unavailable: done
entity-unavailable: done
action-exceptions:
status: exempt
comment: No service actions registered.
reauthentication-flow: todo
parallel-updates: todo
test-coverage: todo
integration-owner: done
docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: No options flow.
# Gold
entity-translations: todo
entity-device-class: todo
devices: done
entity-category:
status: exempt
comment: Single climate entity per device, no diagnostic entities yet.
entity-disabled-by-default:
status: exempt
comment: Single climate entity per device, enabled by default.
discovery: todo
stale-devices: todo
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-data-update: done
docs-known-limitations: done
docs-examples: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,36 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No devices were found on this account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Kumo Cloud account.",
"username": "The email address for your Kumo Cloud account."
}
}
}
},
"exceptions": {
"communication_error": {
"message": "Error communicating with {device_name}"
},
"no_devices": {
"message": "No devices were found in your Mitsubishi Comfort account"
},
"update_failed": {
"message": "{device_name} returned no data"
}
}
}
@@ -82,6 +82,7 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
@@ -13,7 +13,10 @@ from homeassistant.components.device_tracker import (
ATTR_LOCATION_NAME,
TrackerEntity,
)
from homeassistant.components.zone import ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT
from homeassistant.components.zone import (
ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT,
HOME_ZONE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
@@ -21,8 +24,9 @@ from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -93,15 +97,6 @@ async def async_setup_entry(
async_add_entities([entity])
def _zone_state(hass: HomeAssistant, data: dict[str, Any]) -> State | None:
"""Return the state of the zone matching the location name in data, if any."""
if not (location_name := data.get(ATTR_LOCATION_NAME)):
return None
# If a location name is set, set the location to the center of the zone
# to allow the `in_zones` attribute to be populated by the base class.
return hass.states.get(ZONE_ENTITY_ID_FORMAT.format(location_name))
class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
@@ -134,17 +129,13 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
@property
def location_accuracy(self) -> float:
"""Return the gps accuracy of the device."""
if ATTR_GPS not in self._data:
return 0
return self._data.get(ATTR_GPS_ACCURACY, 0)
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
if (gps := self._data.get(ATTR_GPS)) is None:
if not (zone_state := _zone_state(self.hass, self._data)):
return None
return zone_state.attributes.get(ATTR_LATITUDE)
return None
return gps[0]
@@ -152,12 +143,23 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
def longitude(self) -> float | None:
"""Return longitude value of the device."""
if (gps := self._data.get(ATTR_GPS)) is None:
if not (zone_state := _zone_state(self.hass, self._data)):
return None
return zone_state.attributes.get(ATTR_LONGITUDE)
return None
return gps[1]
@property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
if location_name := self._data.get(ATTR_LOCATION_NAME):
if location_name == HOME_ZONE:
return STATE_HOME
if zone_state := self.hass.states.get(
ZONE_ENTITY_ID_FORMAT.format(location_name)
):
return zone_state.name
return location_name
return None
@property
def name(self) -> str:
"""Return the name of the device."""
+23 -1
View File
@@ -21,9 +21,13 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -46,6 +50,7 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -111,6 +116,21 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -195,6 +215,7 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -203,6 +224,7 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
+29 -1
View File
@@ -11,7 +11,12 @@ import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -27,6 +32,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
@@ -73,12 +79,14 @@ from .const import (
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
)
@@ -424,6 +432,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration

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