Compare commits

..

128 Commits

Author SHA1 Message Date
Franck Nijhof ad99929178 Bump version to 2026.6.0b4 2026-06-03 15:09:31 +00:00
Bram Kragten d2672050cf Update frontend to 20260527.4 (#172907) 2026-06-03 15:08:41 +00:00
Sören 74fd636aa6 Add Avea Bluetooth reachability diagnostics (#172898) 2026-06-03 15:08:39 +00:00
Erik Montnemery b4f8fce912 Don't log condition errors when executing WS test_condition (#172897) 2026-06-03 15:08:37 +00:00
Michael Hansen 78a97f99dc Bump intents to 2026.6.1 (#172842) 2026-06-03 15:07:07 +00:00
Franck Nijhof 5d0565f007 Bump version to 2026.6.0b3 2026-06-03 10:03:15 +00:00
Erik Montnemery 083af9ccc7 Add zone occupancy conditions (#172896) 2026-06-03 10:02:17 +00:00
Erik Montnemery 6c87284dee Catch errors when setting up condition in WS subscribe_condition (#172895) 2026-06-03 10:02:15 +00:00
Paulus Schoutsen 0e0b29d16e Regenerate mdi_icons.py for frontend 20260527.3 (#172887) 2026-06-03 10:02:13 +00:00
Bram Kragten 8e493d84f1 Bump frontend to 20260527.3 (#172873) 2026-06-03 10:00:42 +00:00
Joost Lekkerkerker 4e2bc610e3 Bump pySmartThings to 4.0.0 (#172858) 2026-06-03 09:59:38 +00:00
jameson_uk 82d83feda4 Bump aioamazondevices to 14.0.0 (#172857) 2026-06-03 09:59:36 +00:00
Petro31 265fe6d338 Add translations for template device trackers in_zones option (#172850) 2026-06-03 09:59:34 +00:00
Wendelin bb8036f2c8 Automation choose: Add optional note to options (#172837) 2026-06-03 09:59:32 +00:00
Erik Montnemery 387b84ec7b Prevent log spam when WS subscribe_condition is active (#172832) 2026-06-03 09:59:30 +00:00
zhangluofeng 24037fcfa3 Don't create switch entity for switch device type in XThings Cloud (#172828) 2026-06-03 09:59:28 +00:00
Erik Montnemery 994b210588 Make the renamed trigger behavior options backwards compatible (#172822) 2026-06-03 09:59:26 +00:00
Franck Nijhof db6f1426ec Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) 2026-06-03 09:59:24 +00:00
Erik Montnemery 8ce5ba2ba4 Add zone conditions in / not in zone (#172810) 2026-06-03 09:59:22 +00:00
Matthias Alphart b176fb2113 Update knx-frontend to 2026.6.1.213802 (#172806) 2026-06-03 09:59:20 +00:00
Pete Sage ada8a98f87 Log warning on unsupported announce media formats for Sonos (#172614)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-03 09:59:18 +00:00
Heikki Henriksen 763d9879bf prusalink: guard non-string original in config_flow workaround (#172375) 2026-06-03 09:59:16 +00:00
Pete Sage 7bbd0ea472 Replace usages of datetime.now(UTC) with dt_util for Sonos (#172737) 2026-06-03 09:53:03 +00:00
jameson_uk 60f458a372 alexa devices - media player code quality (#172650) 2026-06-03 09:43:11 +00:00
Erik Montnemery 05eada2569 Add zone triggers occupancy detected/cleared (#172438) 2026-06-03 09:35:43 +00:00
Erik Montnemery d2abd7f6ca Add zone entered left triggers (#172412) 2026-06-03 09:35:41 +00:00
Franck Nijhof af08e5e7d0 Bump version to 2026.6.0b2 2026-06-01 21:05:58 +00:00
Franck Nijhof b03d87dc21 Cancel iCloud polling timer on config entry unload (#172793) 2026-06-01 21:05:46 +00:00
Tom d8a9ea1d9d Fix ProxmoxVE missing unused token data (#172782) 2026-06-01 21:05:44 +00:00
J. Nick Koston 5ff07fcc49 Explain why a Snooz device could not be found (#172780) 2026-06-01 21:05:42 +00:00
J. Nick Koston 6f59bb0661 Explain why an LD2410 BLE device could not be found (#172779) 2026-06-01 21:05:40 +00:00
J. Nick Koston c82d32bbae Explain why a Husqvarna Automower BLE device could not be connected to (#172774) 2026-06-01 21:05:38 +00:00
Ingo Fischer 4fbc363965 Filter stale replayed BLE advertisements in Matter BLE proxy (#172773)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:05:36 +00:00
J. Nick Koston 8622f0f4de Explain why an eQ-3 Bluetooth device could not be found (#172770) 2026-06-01 21:05:34 +00:00
J. Nick Koston b49a6b89b6 Bump habluetooth to 6.8.1 (#172768) 2026-06-01 21:05:32 +00:00
J. Nick Koston 0bfd4c44bb Explain why a LED BLE device could not be found (#172764) 2026-06-01 21:05:30 +00:00
J. Nick Koston c09216650f Explain why an INKBIRD device could not be found (#172762) 2026-06-01 21:05:28 +00:00
J. Nick Koston 6057d32636 Explain why a Yale Access Bluetooth device could not be found (#172761) 2026-06-01 21:05:26 +00:00
Bram Kragten 51c9d0c6e5 Bump frontend to 20260527.2 (#172759)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-01 21:05:24 +00:00
J. Nick Koston 323304664e Explain why an Airthings BLE device could not be found (#172758) 2026-06-01 21:05:22 +00:00
A. Gideonse 3dda7d9848 Fix binary sensor defaults for Indevolt (#172714) 2026-06-01 21:05:20 +00:00
A. Gideonse 5e56d74257 Bump indevolt-api to 1.8.3 (#172683) 2026-06-01 21:05:18 +00:00
Thijs W. e5f9c7892a Fix get_play_status function call in frontier silicon (#172705) 2026-06-01 21:01:29 +00:00
Michael a0d713a4a7 Use proper user-agent to fetch feeds (#172655) 2026-06-01 21:01:27 +00:00
jameson_uk 84f4f876b1 media_player platform fixes for Alexa Devices (#172611) 2026-06-01 21:01:25 +00:00
Franck Nijhof 7b06228a5a Bump version to 2026.6.0b1 2026-06-01 16:54:56 +00:00
Paul Bottein 06b2ec22f0 Bump yoto-api to 3.1.5 (#172753) 2026-06-01 16:54:33 +00:00
jameson_uk 7950998083 Bump aioamazondevices to 13.8.2 (#172748) 2026-06-01 16:54:30 +00:00
Maciej Bieniek 86999063d7 Translate the name of the Tractive tracker (#172747) 2026-06-01 16:54:28 +00:00
Maciej Bieniek 9843fdad2c Add missing _attr_name = None for Tractive device tracker (#172746) 2026-06-01 16:54:26 +00:00
Jan Bouwhuis e53914a0ef Fix MQTT device_tracker logging attributes order (#172732) 2026-06-01 16:54:24 +00:00
Franck Nijhof f7afe22318 Skip Overkiz events for unknown device URLs (#172712) 2026-06-01 16:54:22 +00:00
Franck Nijhof acfecd7f5c Convert set_id to int in LG TV RS-232 config flow (#172701) 2026-06-01 16:54:20 +00:00
Franck Nijhof 56057a11e6 Return 404 instead of 500 when media player artwork is unavailable (#172700) 2026-06-01 16:54:18 +00:00
Yardian Support 0d079c57e4 Fix Yardian water hammer diagnostic sensor name (#172698) 2026-06-01 16:54:16 +00:00
Denis Shulyaka 3ad3e1fafb Fix ai_task camera snapshot mime type (#172682) 2026-06-01 16:54:13 +00:00
Josef Zweck 0677ed824f Fix tedee entity availability (#172667)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 16:54:11 +00:00
Jordan Harvey 4b9945e012 Bump pynintendoparental to 2.4.0 (#172666) 2026-06-01 16:54:08 +00:00
Michael 9fa0132b1c Add missing exception translation keys in Ecovacs (#172658) 2026-06-01 16:54:06 +00:00
jameson_uk 10a25368a0 Improve http2 task handling for Alexa Devices (#172649) 2026-06-01 16:54:04 +00:00
epenet fbb68c26b6 Bump tuya-device-handlers to 0.0.22 (#172648) 2026-06-01 16:54:02 +00:00
Michael 25875de414 Add extra device info to FRITZ!Box Tools diagnostics (#172647) 2026-06-01 16:54:00 +00:00
TheJulianJES 22ace88b2c Bump ZHA to 1.4.1 (#172640) 2026-06-01 16:53:57 +00:00
David Knowles a47105d314 Schlage: use lock connected status as availability signal (#172638)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-01 16:53:55 +00:00
Jan Bouwhuis b50bfda00c Fix MQTT device_tracker not saving state on location accuracy changes (#172629) 2026-06-01 16:53:53 +00:00
Sören 0d37319ba9 Improve Avea Bluetooth discovery flow (#172623) 2026-06-01 16:53:51 +00:00
Michael 24a5c75cf2 Show error about missing api permissions while browsing Immich media (#172609) 2026-06-01 16:53:49 +00:00
renovate[bot] dd43b1135d Update rf-protocols to 4.0.1 (#172597) 2026-06-01 16:53:47 +00:00
J. Nick Koston de0a202c4e Explain why a Switchbot device could not be found (#172581) 2026-06-01 16:53:44 +00:00
J. Nick Koston d550d1da90 Expose bluetooth address reachability diagnostics API (#172578) 2026-06-01 16:53:42 +00:00
J. Nick Koston ce8875ae8c Bump habluetooth to 6.8.0 (#172577) 2026-06-01 16:53:40 +00:00
J. Nick Koston 3364096b2b Fix ESPHome update entity stuck on for project versions with build suffix (#172571) 2026-06-01 16:53:38 +00:00
A. Gideonse c2b75b9634 Bugfix: Gen-1 Inverter sensor for Indevolt to display "N/A" when turned off (#172559) 2026-06-01 16:53:36 +00:00
Franck Nijhof ae278d3c80 Sanitize surrogate characters in MeteoAlarm alert attributes (#172545) 2026-06-01 16:53:34 +00:00
Paul Bottein 25f9cd9ab8 Fix Yoto OAuth flow with cloud credentials (#172544)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-01 16:53:31 +00:00
Franck Nijhof 796d82d6ed Add missing ssdp dependency to BraviaTV manifest (#172536) 2026-06-01 16:53:30 +00:00
Franck Nijhof 4b517fb164 Use state-based icon for Hue grouped light (#172535) 2026-06-01 16:53:27 +00:00
Kamil Breguła 2d74091a36 Refresh WLED firmware releases on manual entity update (#172517)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:53:25 +00:00
Franck Nijhof 504e22ee3e Raise errors instead of swallowing exceptions in Toon action handlers (#172511) 2026-06-01 16:53:23 +00:00
Franck Nijhof c95a39c26e Guard Shelly repairs checks for uninitialized RPC devices (#172509) 2026-06-01 16:53:21 +00:00
Franck Nijhof 8ec3eac705 Fix Overkiz UnoIO cover reporting wrong movement direction (#172506) 2026-06-01 16:53:19 +00:00
Franck Nijhof 589d2637c9 Fix ephember crash when zone mode is None (#172504) 2026-06-01 16:53:17 +00:00
Franck Nijhof 26cf728165 Handle missing notAfter field in cert_expiry certificate data (#172503)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 16:51:22 +00:00
Franck Nijhof b61559bdbb Handle malformed response errors in Denon AVR error wrapper (#172502) 2026-06-01 16:06:02 +00:00
Jan Bouwhuis 57259132d9 Silent migrate MQTT protocol version to version 5 if the broker supports it or raise an issue (#172500)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 16:06:00 +00:00
Franck Nijhof 2776e966ff Reduce Wyoming satellite disconnect log to debug level (#172499) 2026-06-01 16:05:58 +00:00
Franck Nijhof 5f9872886d Convert Roomba hw_version to string for device registry (#172497) 2026-06-01 16:05:56 +00:00
Franck Nijhof f728a1bf09 Add missing Flexit BACnet transient operation modes to preset map (#172493) 2026-06-01 16:05:53 +00:00
Franck Nijhof df65132268 Add prog operating mode to Overkiz Atlantic heater HVAC mapping (#172491) 2026-06-01 16:05:51 +00:00
Michael c13822b776 Handle FileNotFoundError in Immich upload_file action (#172490) 2026-06-01 16:05:49 +00:00
Simone Chemelli c6d696db0c Remove redundant definitions in Alexa Devices (#172488) 2026-06-01 16:05:46 +00:00
Franck Nijhof 114c9bbafa Increase ConfigEntryNotReady retry backoff cap from 80s to 10 minutes (#172487) 2026-06-01 16:05:44 +00:00
Franck Nijhof 323ce99fda Fix Tado config flow crash on device activation polling (#172486) 2026-06-01 16:05:42 +00:00
Jan Bouwhuis 7a7ef85db2 Move MQTT protocol setting to main options (#172482) 2026-06-01 16:05:40 +00:00
Franck Nijhof 7ab402618d Handle DAVError in CalDAV get_supported_components (#172479) 2026-06-01 16:05:37 +00:00
Franck Nijhof aa87295a1e Fix Growatt setup failure on API rate limit (#172472) 2026-06-01 16:05:35 +00:00
Simone Chemelli 3bd979e976 Bump samsungtvws to 3.0.5 (#172471) 2026-06-01 16:05:33 +00:00
Paul Bottein 9dddf76548 Name the Broadlink RF transmitter entity (#172468) 2026-06-01 16:05:31 +00:00
Franck Nijhof 1828579f03 Fix Volvo lock crash when API field is missing from coordinator data (#172465) 2026-06-01 16:05:29 +00:00
Bram Kragten 47bca8d8c2 Bump frontend to 20260527.1 (#172462)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-01 16:05:27 +00:00
Paulus Schoutsen 6f3fb5c7bd Add lg_tv_rs232 to LG brand (#172458)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-01 16:05:24 +00:00
TheJulianJES d9b4b5b3d0 Fix Matter BLE proxy blocking startup (#172456) 2026-06-01 16:05:22 +00:00
Ronald van der Meer 342b364af6 Fix Duco regression where entities become unavailable when LAN info fetch fails (#172448) 2026-06-01 16:05:20 +00:00
Simone Chemelli 951cd71741 Discard old events for Alexa Devices (#172446) 2026-06-01 16:05:18 +00:00
Franck Nijhof e86a54f81c Fix Hue light ZeroDivisionError when mirek value is zero (#172442) 2026-06-01 16:05:15 +00:00
Simone Chemelli ba8b33e1a9 Fix Shelly sensor restore when not initialized (#172441) 2026-06-01 16:05:13 +00:00
Franck Nijhof b6c40ba3fc Fix Jellyfin media source crash when entry is not loaded (#172437) 2026-06-01 16:05:11 +00:00
Franck Nijhof f2f29c07c7 Fix SmartThings light checking wrong component for capabilities (#172430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-01 16:05:08 +00:00
Franck Nijhof 50a3ab115d Fix iZone integration broken by python-izone 1.2.10 API change (#172427) 2026-06-01 16:05:06 +00:00
Franck Nijhof c204054847 Convert yamaha_musiccast sw_version to string (#172411) 2026-06-01 16:05:04 +00:00
Jan Bouwhuis 28d6eab2dd Improve MQTT protocol deprecation repair message (#172404)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 16:05:02 +00:00
Manu 6b1ee57bd5 Fix index error in DuckDNS integration (#172392) 2026-06-01 16:05:00 +00:00
J. Nick Koston 7247f95b05 Bump onvif-zeep-async to 4.1.1 (#172391) 2026-06-01 16:04:57 +00:00
J. Nick Koston cdeafdfd42 Bump yalexs to 9.2.1 (#172389) 2026-06-01 16:04:55 +00:00
Abílio Costa 9d60fce72e Fix OMIE sensors not updating on setup (#172383) 2026-06-01 16:04:53 +00:00
Simone Chemelli 2e4c6c4370 Bump aioamazondevices to 13.8.1 (#172382) 2026-06-01 16:04:50 +00:00
J. Nick Koston b7e36e297b Bump dbus-fast to 5.0.16 (#172378) 2026-06-01 16:04:48 +00:00
Stefan Agner 7e178efe63 Reject backup uploads with unsafe inner name (#172368)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 16:04:46 +00:00
puddly 38f25c4b41 Bump ZHA to 1.4.0 (#172357) 2026-06-01 16:04:44 +00:00
torben-iometer 2c2e70a11c bump iometer version to 1.0.1 (#172338) 2026-06-01 16:04:41 +00:00
Linkplay2020 190350aec3 Bump wiim to 1.0.4 (#172334)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com>
2026-06-01 16:04:39 +00:00
tlpeter a87083b6c1 Bump renault-api to 0.5.11 (#172333)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-06-01 16:04:36 +00:00
Mike Degatano d5be54fd40 Migrate analytics integration to config entry setup (#171801)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 16:04:34 +00:00
Mike Degatano 46f2ad9eb2 During onboarding, ensure Supervisor is up to date during hassio setup (#171129)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 16:04:31 +00:00
mhuiskes add75622d6 Fix zeversolar coordinator to raise UpdateFailed on errors (#170507) 2026-06-01 16:04:29 +00:00
Daniel Feinberg 2f334d657d Fix apple_tv HomePod streaming failures when device is idle (#170033)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:04:26 +00:00
Nikhil Deepak fd69d384be Reset MQTT valve opening/closing state at intermediate positions (#165176)
Co-authored-by: jbouwh <jan@jbsoft.nl>
2026-06-01 16:04:24 +00:00
Franck Nijhof fce17c8e6f Bump version to 2026.6.0b0 2026-05-27 16:07:37 +00:00
433 changed files with 2775 additions and 35585 deletions
@@ -24,7 +24,6 @@ The following platforms have extra guidelines:
## Entity platforms
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
## Integration Quality Scale
@@ -27,7 +27,6 @@ The following platforms have extra guidelines:
## Entity platforms
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
## Integration Quality Scale
+7 -7
View File
@@ -344,13 +344,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -380,7 +380,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -394,7 +394,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -523,14 +523,14 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -543,7 +543,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
+7 -7
View File
@@ -36,7 +36,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+1 -1
View File
@@ -39,7 +39,7 @@ on:
env:
CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.7"
HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:python"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
issues: write # To lock issues
pull-requests: write # To lock pull requests
steps:
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
+67 -25
View File
@@ -20,36 +20,22 @@ jobs:
permissions:
issues: write # To label and close stale issues
pull-requests: write # To label and close stale PRs
actions: write # To delete stalebot state
steps:
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 60 day stale policy for PRs
# Used for:
# - PRs
# - No PRs marked as no-stale
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
- name: 60 days stale PRs policy and 90 days stale issue policy
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
days-before-close: 7
days-before-issue-stale: -1
days-before-issue-close: -1
operations-per-run: 150
remove-stale-when-updated: true
operations-per-run: 350
# pr policy
days-before-pr-stale: 60
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "no-stale"
stale-pr-message: >
@@ -62,9 +48,65 @@ jobs:
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# issue policy
days-before-issue-stale: 90
days-before-issue-close: 7
# Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit.
# This is only used for issues.
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:
# - Issues
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest Home Assistant version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
# The 30 day stale policy for issues
# Used for:
# - Issues that are pending more information (incomplete issues)
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
days-before-stale: 14
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted"
stale-issue-message: >
-1
View File
@@ -286,7 +286,6 @@ homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.huum.*
homeassistant.components.hvv_departures.*
homeassistant.components.hydrawise.*
homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
Generated
-6
View File
@@ -501,8 +501,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
/tests/components/envertech_evt800/ @daniel-bergmann-00
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
@@ -720,8 +718,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/heatmiser/ @andylockran
/homeassistant/components/hegel/ @boazca
/tests/components/hegel/ @boazca
/homeassistant/components/helty/ @ebaschiera
/tests/components/helty/ @ebaschiera
/homeassistant/components/heos/ @andrewsayre
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
@@ -840,8 +836,6 @@ CLAUDE.md @home-assistant/core
/tests/components/imgw_pib/ @bieniu
/homeassistant/components/immich/ @mib1185
/tests/components/immich/ @mib1185
/homeassistant/components/imou/ @Imou-OpenPlatform
/tests/components/imou/ @Imou-OpenPlatform
/homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
+4 -2
View File
@@ -92,7 +92,8 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
filter="tar",
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -118,7 +119,8 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
filter="tar",
members=securetar.secure_path(istf),
filter="fully_trusted",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_CUBIC_METER,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
@@ -46,8 +47,6 @@ from .coordinator import (
PARALLEL_UPDATES = 1
PARTS_PER_CUBIC_METER = "p/m³"
@dataclass(frozen=True, kw_only=True)
class AccuWeatherSensorDescription(SensorEntityDescription):
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Mold",
entity_registry_enabled_default=False,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Ragweed",
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Tree",
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
+3 -2
View File
@@ -4,6 +4,7 @@ import base64
from collections import deque
from collections.abc import AsyncIterator, Callable, Iterable
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
from mimetypes import guess_file_type
from pathlib import Path
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util import slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import (
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
)
if (
content.native.container is not None
and content.native.container.expires_at > dt_util.utcnow()
and content.native.container.expires_at > datetime.now(UTC)
):
container_id = content.native.container.id
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["apprise"],
"quality_scale": "legacy",
"requirements": ["apprise==1.11.0"]
"requirements": ["apprise==1.9.1"]
}
+2 -3
View File
@@ -4,7 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
"iot_class": "local_push",
"quality_scale": "legacy",
"requirements": ["arwn-client==0.2.1"]
"iot_class": "local_polling",
"quality_scale": "legacy"
}
+121 -80
View File
@@ -3,26 +3,113 @@
import logging
from typing import Any
from arwn_client import parse_message
from homeassistant.components import mqtt
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.util.json import json_loads_object
_LOGGER = logging.getLogger(__name__)
DOMAIN = "arwn"
DATA_ARWN = "arwn"
TOPIC = "arwn/#"
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
"""Given a topic, dynamically create the right sensor type.
Async friendly.
"""
parts = topic.split("/")
unit = payload.get("units", "")
domain = parts[1]
if domain == "temperature":
name = parts[2]
if unit == "F":
unit = UnitOfTemperature.FAHRENHEIT
else:
unit = UnitOfTemperature.CELSIUS
return [
ArwnSensor(
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
)
]
if domain == "moisture":
name = f"{parts[2]} Moisture"
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
if domain == "rain":
if len(parts) >= 3 and parts[2] == "today":
return [
ArwnSensor(
topic,
"Rain Since Midnight",
"since_midnight",
UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
)
]
return [
ArwnSensor(
topic + "/total",
"Total Rainfall",
"total",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
ArwnSensor(
topic + "/rate",
"Rainfall Rate",
"rate",
unit,
device_class=SensorDeviceClass.PRECIPITATION,
),
]
if domain == "barometer":
return [
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
]
if domain == "wind":
return [
ArwnSensor(
topic + "/speed",
"Wind Speed",
"speed",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/gust",
"Wind Gust",
"gust",
unit,
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
topic + "/dir",
"Wind Direction",
"direction",
DEGREE,
"mdi:compass",
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
]
return None
def _slug(name: str) -> str:
return f"sensor.arwn_{slugify(name)}"
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -31,25 +118,28 @@ async def async_setup_platform(
) -> None:
"""Set up the ARWN platform."""
# Make sure MQTT integration is enabled and the client is available
if not await mqtt.async_wait_for_mqtt_client(hass):
_LOGGER.error("MQTT integration is not available")
return
@callback
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
"""Process MQTT events as sensors."""
try:
event = json_loads_object(msg.payload)
device = parse_message(msg.topic, event)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Failed to parse ARWN message on topic %s",
msg.topic,
exc_info=True,
)
return
"""Process events as sensors.
if device is None:
When a new event on our topic (arwn/#) is received we map it
into a known kind of sensor based on topic name. If we've
never seen this before, we keep this sensor around in a global
cache. If we have seen it before, we update the values of the
existing sensor. Either way, we push an ha state update at the
end for the new event we've seen.
This lets us dynamically incorporate sensors without any
configuration on our side.
"""
event = json_loads_object(msg.payload)
sensors = discover_sensors(msg.topic, event)
if not sensors:
return
if (store := hass.data.get(DATA_ARWN)) is None:
@@ -58,71 +148,22 @@ async def async_setup_platform(
if "timestamp" in event:
del event["timestamp"]
new_sensors: list[ArwnSensor] = []
for reading in device.readings:
if not reading.expose:
continue
unique_id = (
f"{msg.topic}/{reading.sensor_key}"
if len(device.readings) > 1
else msg.topic
)
try:
device_class = (
SensorDeviceClass(reading.device_class)
if reading.device_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown device_class=%s for sensor %s",
reading.device_class,
reading.sensor_name,
)
device_class = None
try:
state_class = (
SensorStateClass(reading.state_class)
if reading.state_class
else None
)
except ValueError:
_LOGGER.debug(
"Unknown state_class=%s for sensor %s",
reading.state_class,
reading.sensor_name,
)
state_class = None
if unique_id not in store:
sensor = ArwnSensor(
unique_id=unique_id,
name=reading.sensor_name,
state_key=reading.sensor_key,
units=reading.unit,
icon=reading.icon,
device_class=device_class,
state_class=state_class,
event=event,
)
store[unique_id] = sensor
for sensor in sensors:
if sensor.name not in store:
sensor.hass = hass
sensor.set_event(event)
store[sensor.name] = sensor
_LOGGER.debug(
"Registering sensor %(name)s => %(event)s",
{"name": reading.sensor_name, "event": event},
{"name": sensor.name, "event": event},
)
new_sensors.append(sensor)
async_add_entities((sensor,), True)
else:
_LOGGER.debug(
"Recording sensor %(name)s => %(event)s",
{"name": reading.sensor_name, "event": event},
{"name": sensor.name, "event": event},
)
store[unique_id].set_event(event)
if new_sensors:
async_add_entities(new_sensors, True)
store[sensor.name].set_event(event)
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
def __init__(
self,
unique_id: str,
topic: str,
name: str,
state_key: str,
units: str,
icon: str | None = None,
device_class: SensorDeviceClass | None = None,
state_class: SensorStateClass | None = None,
event: dict[str, Any] | None = None,
) -> None:
"""Initialize the sensor."""
self.entity_id = _slug(name)
self._attr_name = name
self._attr_unique_id = unique_id
# This mqtt topic for the sensor which is its uid
self._attr_unique_id = topic
self._state_key = state_key
self._attr_native_unit_of_measurement = units
self._attr_icon = icon
self._attr_device_class = device_class
self._attr_state_class = state_class
if event is not None:
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(state_key)
def set_event(self, event: dict[str, Any]) -> None:
"""Update the sensor with the most recent event."""
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(self._state_key)
ev: dict[str, Any] = {}
ev.update(event)
self._attr_extra_state_attributes = ev
self._attr_native_value = ev.get(self._state_key)
self.async_write_ha_state()
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
}
+6 -5
View File
@@ -6,6 +6,7 @@ from blebox_uniapi.box import Box
from blebox_uniapi.error import Error
from blebox_uniapi.session import ApiHost
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -17,9 +18,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DEFAULT_SETUP_TIMEOUT
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
from .helpers import get_maybe_authenticated_session
type BleBoxConfigEntry = ConfigEntry[Box]
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
@@ -33,6 +35,8 @@ PLATFORMS = [
Platform.UPDATE,
]
PARALLEL_UPDATES = 0
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
"""Set up BleBox devices from a config entry."""
@@ -54,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
raise ConfigEntryNotReady from ex
coordinator = BleBoxCoordinator(hass, entry, product)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.runtime_data = product
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -11,11 +11,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
BINARY_SENSOR_TYPES = (
BinarySensorEntityDescription(
key="moisture",
@@ -30,27 +27,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxBinarySensorEntity(coordinator, feature, description)
for feature in coordinator.box.features.get("binary_sensors", [])
BleBoxBinarySensorEntity(feature, description)
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
for description in BINARY_SENSOR_TYPES
if description.key == feature.device_class
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
"""Representation of a BleBox binary sensor feature."""
def __init__(
self,
coordinator: BleBoxCoordinator,
feature: BinarySensorFeature,
description: BinarySensorEntityDescription,
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
) -> None:
"""Initialize a BleBox binary sensor feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self.entity_description = description
@property
+5 -13
View File
@@ -7,11 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
async def async_setup_entry(
@@ -20,22 +16,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox button entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxButtonEntity(coordinator, feature)
for feature in coordinator.box.features.get("buttons", [])
BleBoxButtonEntity(feature)
for feature in config_entry.runtime_data.features.get("buttons", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
"""Representation of BleBox buttons."""
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
"""Initialize a BleBox button feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._attr_icon = self.get_icon()
def get_icon(self) -> str | None:
@@ -52,7 +45,6 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
return "mdi:arrow-down-circle"
return None
@blebox_command
async def async_press(self) -> None:
"""Handle the button press."""
await self._feature.set()
+5 -8
View File
@@ -1,5 +1,6 @@
"""BleBox climate entity."""
from datetime import timedelta
from typing import Any
import blebox_uniapi.climate
@@ -16,9 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
BLEBOX_TO_HVACMODE = {
0: HVACMode.OFF,
@@ -40,12 +40,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox climate entity."""
coordinator = config_entry.runtime_data
entities = [
BleBoxClimateEntity(coordinator, feature)
for feature in coordinator.box.features.get("climates", [])
BleBoxClimateEntity(feature)
for feature in config_entry.runtime_data.features.get("climates", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
@@ -109,7 +108,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
"""Return the desired thermostat temperature."""
return self._feature.desired
@blebox_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the climate entity mode."""
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
@@ -118,7 +116,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
await self._feature.async_off()
@blebox_command
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the thermostat temperature."""
value = kwargs[ATTR_TEMPERATURE]
+48 -88
View File
@@ -33,14 +33,23 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
STEP_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
def create_schema(previous_input=None):
"""Create a schema with given values as default."""
if previous_input is not None:
host = previous_input[CONF_HOST]
port = previous_input[CONF_PORT]
else:
host = DEFAULT_HOST
port = DEFAULT_PORT
return vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_PORT, default=port): int,
vol.Inclusive(CONF_USERNAME, "auth"): str,
vol.Inclusive(CONF_PASSWORD, "auth"): str,
}
)
LOG_MSG = {
@@ -60,44 +69,18 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
self.device_config: dict[str, Any] = {}
def handle_step_exception(
self, exception, schema, host, port, message_id, log_fn, step_id
self, step, exception, schema, host, port, message_id, log_fn
):
"""Handle step exceptions."""
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
return self.async_show_form(
step_id=step_id,
step_id="user",
data_schema=schema,
errors={"base": message_id},
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
"""Try to connect to the device; return product or an error form."""
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
try:
return await Box.async_from_host(api_host), None
except UnsupportedBoxVersion as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
)
except UnauthorizedRequest as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
)
except Error as ex:
return None, self.handle_step_exception(
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
)
except RuntimeError as ex:
return None, self.handle_step_exception(
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -162,11 +145,12 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle initial user-triggered config step."""
hass = self.hass
schema = create_schema(user_input)
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=STEP_SCHEMA,
data_schema=schema,
errors={},
description_placeholders={},
)
@@ -189,60 +173,36 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="user"
)
if error is not None:
return error
assert product is not None
try:
product = await Box.async_from_host(api_host)
except UnsupportedBoxVersion as ex:
return self.handle_step_exception(
"user",
ex,
schema,
host,
port,
UNSUPPORTED_VERSION,
_LOGGER.debug,
)
except UnauthorizedRequest as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
)
except Error as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
)
except RuntimeError as ex:
return self.handle_step_exception(
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
)
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=product.name, data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of a BleBox device."""
reconfigure_entry = self._get_reconfigure_entry()
if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_SCHEMA, reconfigure_entry.data
),
)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
websession = get_maybe_authenticated_session(self.hass, password, username)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
product, error = await self._async_from_host_or_form(
api_host, user_input, step_id="reconfigure"
)
if error is not None:
return error
assert product is not None
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
self._abort_if_unique_id_mismatch()
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
if username is not None:
data_updates[CONF_USERNAME] = username
if password is not None:
data_updates[CONF_PASSWORD] = password
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=data_updates,
)
@@ -1,48 +0,0 @@
"""DataUpdateCoordinator for BleBox devices."""
from datetime import timedelta
import logging
from blebox_uniapi.box import Box
from blebox_uniapi.error import Error
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
class BleBoxCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a single BleBox device."""
config_entry: BleBoxConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=5),
)
self.box = box
async def _async_update_data(self) -> None:
"""Fetch data from the BleBox device."""
try:
await self.box.async_update_data()
except Error as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
+5 -19
View File
@@ -17,11 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
BLEBOX_TO_COVER_DEVICE_CLASSES = {
"gate": CoverDeviceClass.GATE,
@@ -63,22 +59,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxCoverEntity(coordinator, feature)
for feature in coordinator.box.features.get("covers", [])
BleBoxCoverEntity(feature)
for feature in config_entry.runtime_data.features.get("covers", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
"""Representation of a BleBox cover feature."""
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
) -> None:
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
"""Initialize a BleBox cover feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
@@ -142,40 +135,33 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
"""Return whether cover is closed."""
return self._is_state(CoverState.CLOSED)
@blebox_command
async def async_open_cover(self, **kwargs: Any) -> None:
"""Fully open the cover position."""
await self._feature.async_open()
@blebox_command
async def async_close_cover(self, **kwargs: Any) -> None:
"""Fully close the cover position."""
await self._feature.async_close()
@blebox_command
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Fully open the cover tilt."""
position = 50 if self._feature.is_tilt_180 else 0
await self._feature.async_set_tilt_position(position)
@blebox_command
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Fully close the cover tilt."""
# note: values are reversed
await self._feature.async_set_tilt_position(100)
@blebox_command
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Set the cover position."""
position = kwargs[ATTR_POSITION]
await self._feature.async_set_position(100 - position)
@blebox_command
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._feature.async_stop()
@blebox_command
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Set the tilt position."""
position = kwargs[ATTR_TILT_POSITION]
+15 -5
View File
@@ -1,20 +1,23 @@
"""Base entity for the BleBox devices integration."""
import logging
from blebox_uniapi.error import Error
from blebox_uniapi.feature import Feature
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import BleBoxCoordinator
_LOGGER = logging.getLogger(__name__)
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
class BleBoxEntity[_FeatureT: Feature](Entity):
"""Implements a common class for entities representing a BleBox feature."""
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
def __init__(self, feature: _FeatureT) -> None:
"""Initialize a BleBox entity."""
super().__init__(coordinator)
self._feature = feature
self._attr_name = feature.full_name
self._attr_unique_id = feature.unique_id
@@ -27,3 +30,10 @@ class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
sw_version=product.firmware_version,
configuration_url=f"http://{product.address}",
)
async def async_update(self) -> None:
"""Update the entity state."""
try:
await self._feature.async_update()
except Error as ex:
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
+7 -13
View File
@@ -1,5 +1,6 @@
"""BleBox light entities implementation."""
from datetime import timedelta
import logging
import math
from typing import Any
@@ -23,13 +24,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
from .util import blebox_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
@@ -38,12 +37,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxLightEntity(coordinator, feature)
for feature in coordinator.box.features.get("lights", [])
BleBoxLightEntity(feature)
for feature in config_entry.runtime_data.features.get("lights", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
COLOR_MODE_MAP = {
@@ -63,11 +61,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
) -> None:
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
"""Initialize a BleBox light."""
super().__init__(coordinator, feature)
super().__init__(feature)
if feature.effect_list:
self._attr_supported_features = LightEntityFeature.EFFECT
@@ -169,7 +165,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return None
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
@blebox_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
@@ -229,7 +224,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
" effect list."
) from exc
@blebox_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._feature.async_off()
+6 -9
View File
@@ -1,6 +1,6 @@
"""BleBox sensor entities."""
from datetime import datetime
from datetime import datetime, timedelta
import blebox_uniapi.sensor
@@ -28,10 +28,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=5)
SENSOR_TYPES = (
@@ -125,14 +124,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxSensorEntity(coordinator, feature, description)
for feature in coordinator.box.features.get("sensors", [])
BleBoxSensorEntity(feature, description)
for feature in config_entry.runtime_data.features.get("sensors", [])
for description in SENSOR_TYPES
if description.key == feature.device_class
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
@@ -140,12 +138,11 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
def __init__(
self,
coordinator: BleBoxCoordinator,
feature: blebox_uniapi.sensor.BaseSensor,
description: SensorEntityDescription,
) -> None:
"""Initialize a BleBox sensor feature."""
super().__init__(coordinator, feature)
super().__init__(feature)
self.entity_description = description
@property
+1 -18
View File
@@ -2,9 +2,7 @@
"config": {
"abort": {
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -13,16 +11,6 @@
},
"flow_title": "{name} ({host})",
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Update the connection settings for your BleBox device.",
"title": "Reconfigure BleBox device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]",
@@ -34,10 +22,5 @@
"title": "Set up your BleBox device"
}
}
},
"exceptions": {
"update_failed": {
"message": "An error occurred while communicating with the BleBox device: {error}"
}
}
}
+5 -8
View File
@@ -1,5 +1,6 @@
"""BleBox switch implementation."""
from datetime import timedelta
from typing import Any
import blebox_uniapi.switch
@@ -10,9 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
from .util import blebox_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
@@ -21,12 +21,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox switch entity."""
coordinator = config_entry.runtime_data
entities = [
BleBoxSwitchEntity(coordinator, feature)
for feature in coordinator.box.features.get("switches", [])
BleBoxSwitchEntity(feature)
for feature in config_entry.runtime_data.features.get("switches", [])
]
async_add_entities(entities)
async_add_entities(entities, True)
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
@@ -39,12 +38,10 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
"""Return whether switch is on."""
return self._feature.is_on
@blebox_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self._feature.async_turn_on()
@blebox_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self._feature.async_turn_off()
+5 -15
View File
@@ -18,10 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import BleBoxConfigEntry
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(hours=1)
@@ -35,12 +33,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox update entry."""
coordinator = config_entry.runtime_data
entities = [
BleBoxUpdateEntity(coordinator, feature)
for feature in coordinator.box.features.get("updates", [])
BleBoxUpdateEntity(feature)
for feature in config_entry.runtime_data.features.get("updates", [])
]
async_add_entities(entities, update_before_add=True)
async_add_entities(entities, True)
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
@@ -51,16 +48,9 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@property
def should_poll(self) -> bool:
"""Return True because firmware versions cannot be fetched via coordinator."""
return True
def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
) -> None:
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
"""Initialize the update entity."""
super().__init__(coordinator, feature)
super().__init__(feature)
self._in_progress_old_version: str | None = None
self._poll_cancel: CALLBACK_TYPE | None = None
self._poll_attempts: int = 0
-29
View File
@@ -1,29 +0,0 @@
"""Utilities for BleBox."""
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate
from blebox_uniapi.error import Error
from homeassistant.exceptions import HomeAssistantError
from .entity import BleBoxEntity
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
"""Decorate BleBox calls that send commands to the device.
Catches BleBox errors and refreshes the coordinator after the command.
"""
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(self, *args, **kwargs)
except Error as err:
raise HomeAssistantError(str(err)) from err
finally:
await self.coordinator.async_refresh()
return handler
@@ -24,7 +24,6 @@ from homeassistant.components.alexa import (
entities as alexa_entities,
errors as alexa_errors,
)
from homeassistant.components.frontend import DATA_THEMES
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
@@ -509,15 +508,6 @@ class DownloadSupportPackageView(HomeAssistantView):
"custom_integrations": custom_integrations,
}
@callback
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
"""Collect information about user-installed custom themes."""
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
return {
"count": len(themes),
"themes": sorted(themes),
}
async def _generate_markdown(
self,
hass: HomeAssistant,
@@ -579,25 +569,6 @@ class DownloadSupportPackageView(HomeAssistantView):
)
markdown += "\n</details>\n\n"
# Add custom themes information
try:
themes_info = self._get_themes_info(hass)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Custom Themes\n\n"
markdown += "Unable to collect themes information\n\n"
else:
markdown += "## Custom Themes\n\n"
markdown += f"Custom themes: {themes_info['count']}\n\n"
if themes_info["themes"]:
markdown += "<details><summary>Custom themes</summary>\n\n"
markdown += "Name\n"
markdown += "---\n"
for theme in themes_info["themes"]:
markdown += f"{theme}\n"
markdown += "\n</details>\n\n"
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
@@ -1,7 +1,6 @@
"""Provide functionality to keep track of devices."""
import asyncio
import logging
from typing import TYPE_CHECKING, Any, final
from propcache.api import cached_property
@@ -23,7 +22,6 @@ from homeassistant.core import (
EventStateChangedData,
HomeAssistant,
State,
async_get_hass_or_none,
callback,
)
from homeassistant.helpers import (
@@ -39,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -55,8 +52,6 @@ from .const import (
SourceType,
)
_LOGGER = logging.getLogger(__name__)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@@ -169,35 +164,11 @@ class BaseTrackerEntity(Entity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "battery_level" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated battery_level property on "
"a subclass of BaseTrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
return None
@@ -241,38 +212,13 @@ class TrackerEntity(
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
# If we reported setting deprecated _attr_location_name
__deprecated_attr_location_name_reported = False
__in_zones: list[str] | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "location_name" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated location_name property on "
"an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
@@ -303,32 +249,7 @@ class TrackerEntity(
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
if (location_name := self._attr_location_name) is not None:
if (
not self.__deprecated_attr_location_name_reported
and not self.__class__.__module__.startswith(
"homeassistant.components."
)
):
report_issue = async_suggest_report_issue(
self.hass, module=self.__class__.__module__
)
_LOGGER.warning(
(
"%s::%s is setting the deprecated _attr_location_name attribute "
"on an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
self.__class__.__module__,
self.__class__.__name__,
report_issue,
)
self.__deprecated_attr_location_name_reported = True
return location_name
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
if warnings is None:
return []
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
now = datetime.now(UTC)
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
@property
@@ -106,7 +106,7 @@ async def async_migrate_entry(
new_options = {**config_entry.options}
if config_entry.minor_version < 2:
# Add defaults only if they're not already present
# Add defaults only if theyre not already present
if "stt_auto_language" not in new_options:
new_options["stt_auto_language"] = False
if "stt_model" not in new_options:
@@ -221,7 +221,6 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
):
try:
value = float(current_state.state)
# pylint: disable-next=home-assistant-enforce-utcnow
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
client.get_or_create_sensor(energyid_key).update(value, timestamp)
except ValueError, TypeError:
@@ -1,37 +0,0 @@
"""Envertech EVT800 integration."""
from pyenvertechevt800 import EnvertechEVT800
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import EnvertechEVT800Coordinator
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Set up Envertech EVT800 from a config entry."""
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
evt800.start()
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,60 +0,0 @@
"""Config flow for the ENVERTECH EVT800 integration."""
from typing import Any
from pyenvertechevt800 import EnvertechEVT800
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
SCHEMA_DEVICE = vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Envertech EVT800."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First step in config flow."""
errors: dict[str, str] = {}
if user_input is not None:
ip_address = user_input[CONF_IP_ADDRESS]
port = user_input[CONF_PORT]
self._async_abort_entries_match(
{
CONF_IP_ADDRESS: ip_address,
CONF_PORT: port,
}
)
evt800 = EnvertechEVT800(ip_address, port)
can_connect = await evt800.test_connection()
if not can_connect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(
title="Envertech EVT800",
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
)
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
)
@@ -1,11 +0,0 @@
"""Constants for the ENVERTECH EVT800 integration."""
from homeassistant.const import Platform
DOMAIN = "envertech_evt800"
PLATFORMS = [Platform.SENSOR]
DEFAULT_PORT = 14889
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
DEFAULT_SCAN_INTERVAL = 60
@@ -1,44 +0,0 @@
"""Coordinator for Envertech EVT800 integration."""
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
import pyenvertechevt800
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import EnvertechEVT800ConfigEntry
_LOGGER = logging.getLogger(__name__)
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Data update coordinator for Envertech EVT800."""
config_entry: EnvertechEVT800ConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: pyenvertechevt800.EnvertechEVT800,
config_entry: EnvertechEVT800ConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
config_entry=config_entry,
)
self.client = client
client.set_data_listener(self.async_set_updated_data)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device."""
return self.client.data
@@ -1,29 +0,0 @@
"""Envertech EVT800 entity."""
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EnvertechEVT800Coordinator
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
"""Envertech EVT800 entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
"""Initialize Envertech EVT800 entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
manufacturer="Envertech",
model_id="EVT800",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.client.online
@@ -1,12 +0,0 @@
{
"domain": "envertech_evt800",
"name": "ENVERTECH EVT800",
"codeowners": ["@daniel-bergmann-00"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/envertech_evt800",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyenvertechevt800"],
"quality_scale": "bronze",
"requirements": ["pyenvertechevt800==0.2.4"]
}
@@ -1,90 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: done
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: todo
comment: |
The integration does not have any authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Integration connects to a single device
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations:
status: exempt
comment: |
The integration does not have any own exceptions.
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
The integration does not support repairing issues.
stale-devices:
status: exempt
comment: |
This integration connects to a single device per configuration entry.
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: |
No websession is used
strict-typing: todo
@@ -1,185 +0,0 @@
"""Envertech EVT800 sensor."""
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import EnvertechEVT800ConfigEntry
from .coordinator import EnvertechEVT800Coordinator
from .entity import EnvertechEVT800Entity
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="id_1",
entity_registry_enabled_default=False,
translation_key="mppt_id_1",
),
SensorEntityDescription(
key="id_2",
entity_registry_enabled_default=False,
translation_key="mppt_id_2",
),
SensorEntityDescription(
key="input_voltage_1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=2,
translation_key="input_voltage_1",
),
SensorEntityDescription(
key="input_voltage_2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=2,
translation_key="input_voltage_2",
),
SensorEntityDescription(
key="power_1",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_display_precision=0,
translation_key="power_1",
),
SensorEntityDescription(
key="power_2",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_display_precision=0,
translation_key="power_2",
),
SensorEntityDescription(
key="current_1",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=2,
translation_key="current_1",
),
SensorEntityDescription(
key="current_2",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=2,
translation_key="current_2",
),
SensorEntityDescription(
key="ac_frequency_1",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
translation_key="ac_frequency_1",
),
SensorEntityDescription(
key="ac_frequency_2",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
translation_key="ac_frequency_2",
),
SensorEntityDescription(
key="ac_voltage_1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=0,
translation_key="ac_voltage_1",
),
SensorEntityDescription(
key="ac_voltage_2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=0,
translation_key="ac_voltage_2",
),
SensorEntityDescription(
key="temperature_1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
translation_key="temperature_1",
),
SensorEntityDescription(
key="temperature_2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
translation_key="temperature_2",
),
SensorEntityDescription(
key="total_energy_1",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
translation_key="total_energy_1",
),
SensorEntityDescription(
key="total_energy_2",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=2,
translation_key="total_energy_2",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnvertechEVT800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Envertech EVT800 sensors."""
coordinator = config_entry.runtime_data
async_add_entities(
EnvertechEVT800Sensor(coordinator, description) for description in SENSORS
)
class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity):
"""Representation of an Envertech EVT800 sensor."""
def __init__(
self,
coordinator: EnvertechEVT800Coordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.client.data.get(self.entity_description.key)
@property
def available(self) -> bool:
"""Unavailable if evt800 isn't connected."""
return super().available and self.native_value is not None
@@ -1,76 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"ip_address": "The IP address of your Envertech EVT800 device.",
"port": "The Port of your Envertech EVT800 device."
},
"description": "Enter your EVT800 device information.",
"title": "Setup EVT800 device"
}
}
},
"entity": {
"sensor": {
"ac_frequency_1": {
"name": "AC Frequency MPPT 1"
},
"ac_frequency_2": {
"name": "AC Frequency MPPT 2"
},
"ac_voltage_1": {
"name": "AC Voltage MPPT 1"
},
"ac_voltage_2": {
"name": "AC Voltage MPPT 2"
},
"current_1": {
"name": "DC Current MPPT 1"
},
"current_2": {
"name": "DC Current MPPT 2"
},
"input_voltage_1": {
"name": "DC Voltage MPPT 1"
},
"input_voltage_2": {
"name": "DC Voltage MPPT 2"
},
"mppt_id_1": {
"name": "MPPT ID 1"
},
"mppt_id_2": {
"name": "MPPT ID 2"
},
"power_1": {
"name": "DC Power MPPT 1"
},
"power_2": {
"name": "DC Power MPPT 2"
},
"temperature_1": {
"name": "Temperature MPPT 1"
},
"temperature_2": {
"name": "Temperature MPPT 2"
},
"total_energy_1": {
"name": "Total Energy MPPT 1"
},
"total_energy_2": {
"name": "Total Energy MPPT 2"
}
}
}
}
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
from env_canada import ECAirQuality, ECMap, ECWeather
from env_canada import ECAirQuality, ECRadar, ECWeather
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
radar_data = ECRadar(coordinates=(lat, lon))
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -1,6 +1,6 @@
"""Support for the Environment Canada radar imagery."""
from env_canada import ECMap
from env_canada import ECRadar
import voluptuous as vol
from homeassistant.components.camera import Camera
@@ -11,20 +11,13 @@ from homeassistant.helpers.entity_platform import (
)
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import ATTR_OBSERVATION_TIME
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
SERVICE_SET_RADAR_TYPE = "set_radar_type"
SET_RADAR_TYPE_SCHEMA: VolDictType = {
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
}
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
"Rain": "rain",
"Snow": "snow",
"Precipitation type": "precip_type",
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
}
@@ -45,13 +38,13 @@ async def async_setup_entry(
)
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
"""Implementation of an Environment Canada radar camera."""
_attr_has_entity_name = True
_attr_translation_key = "radar"
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
"""Initialize the camera."""
super().__init__(coordinator)
Camera.__init__(self)
@@ -83,13 +76,6 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
async def async_set_radar_type(self, radar_type: str) -> None:
"""Set the type of radar to retrieve."""
if radar_type == "Auto":
# Choose rain for months April through October, snow otherwise
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
else:
layer = _RADAR_TYPE_TO_LAYER[radar_type]
# Apply new layer and clear cache to force refresh
self.radar_object.layer = layer
self.radar_object.clear_cache()
await self.coordinator.async_request_refresh()
self.radar_object.precip_type = radar_type.lower()
await self.radar_object.update()
@@ -5,7 +5,7 @@ from datetime import timedelta
import logging
import xml.etree.ElementTree as ET
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -17,7 +17,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type ECConfigEntry = ConfigEntry[ECRuntimeData]
type ECDataType = ECAirQuality | ECMap | ECWeather
type ECDataType = ECAirQuality | ECRadar | ECWeather
@dataclass
@@ -25,7 +25,7 @@ class ECRuntimeData:
"""Class to hold EC runtime data."""
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
radar_coordinator: ECDataUpdateCoordinator[ECMap]
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
@@ -12,11 +12,10 @@ set_radar_type:
fields:
radar_type:
required: true
example: Rain
example: Snow
selector:
select:
options:
- "Auto"
- "Rain"
- "Snow"
- "Precipitation type"
+2 -2
View File
@@ -1,6 +1,7 @@
"""Support for entities of the Evohome integration."""
from collections.abc import Mapping
from datetime import UTC, datetime
import logging
from typing import Any
@@ -13,7 +14,6 @@ from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .coordinator import EvoDataUpdateCoordinator
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
or self._schedule is None
or (
(until := self._setpoints.get("next_sp_from")) is not None
and until < dt_util.utcnow()
and until < datetime.now(UTC)
)
): # must use self._setpoints, not self.setpoints
await get_schedule()
+2 -3
View File
@@ -1,6 +1,6 @@
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any, NotRequired, TypedDict
from evohomeasync.auth import (
@@ -12,7 +12,6 @@ from evohomeasync2.auth import AbstractTokenManager
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from .const import STORAGE_KEY, STORAGE_VER
@@ -92,7 +91,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
if session_id_expires is None:
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
else:
self._session_id_expires = datetime.fromisoformat(session_id_expires)
+1 -1
View File
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER]
PLATFORMS: list[Platform] = [Platform.BUTTON]
async def async_setup_entry(
-1
View File
@@ -6,4 +6,3 @@ import logging
DOMAIN = "fluss"
LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(minutes=30)
COMMAND_REFRESH_COOLDOWN = 10
+12 -20
View File
@@ -13,11 +13,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL
from .const import LOGGER, UPDATE_INTERVAL
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
@@ -36,24 +35,18 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
name=f"Fluss+ ({slugify(api_key[:8])})",
config_entry=config_entry,
update_interval=UPDATE_INTERVAL,
request_refresh_debouncer=Debouncer(
hass,
LOGGER,
cooldown=COMMAND_REFRESH_COOLDOWN,
immediate=False,
),
)
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
"""Return per-device status."""
async def _async_get_connectivity(self, device_id: str) -> bool:
"""Return connectivity for a device; False if the status call fails."""
try:
response = await self.api.async_get_device_status(device_id)
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err
return response["status"]
status = await self.api.async_get_device_status(device_id)
except FlussApiClientError:
return False
return status["status"]["internetConnected"]
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch Fluss+ devices and merge per-device status."""
"""Fetch Fluss+ devices and merge per-device connectivity status."""
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
@@ -66,11 +59,10 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
for device in devices["devices"]
if device["userPermissions"]["canUseWiFi"]
]
statuses = await asyncio.gather(
*(self._async_get_status(d["deviceId"]) for d in device_list)
connectivity = await asyncio.gather(
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
)
return {
device["deviceId"]: {**device, **status}
for device, status in zip(device_list, statuses, strict=False)
device["deviceId"]: {**device, "internetConnected": connected}
for device, connected in zip(device_list, connectivity, strict=False)
}
-89
View File
@@ -1,89 +0,0 @@
"""Cover platform for Fluss+ devices that report an open/closed status."""
from typing import Any
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 0
STATUS_OPEN = "Open"
STATUS_CLOSED = "Closed"
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fluss covers for devices that report an open/closed status."""
coordinator = entry.runtime_data
added_device_ids: set[str] = set()
def _async_add_new_entities() -> None:
new_entities = [
FlussCover(coordinator, device_id, device)
for device_id, device in coordinator.data.items()
if "openCloseStatus" in device and device_id not in added_device_ids
]
if not new_entities:
return
added_device_ids.update(entity.device_id for entity in new_entities)
async_add_entities(new_entities)
_async_add_new_entities()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
class FlussCover(FlussEntity, CoverEntity):
"""Representation of a Fluss+ cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_name = None
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
@property
def available(self) -> bool:
"""Return True only when the device is online."""
return super().available and self.device["internetConnected"]
@property
def is_closed(self) -> bool | None:
"""Return whether the cover is closed."""
status = self.device.get("openCloseStatus")
if status == STATUS_CLOSED:
return True
if status == STATUS_OPEN:
return False
return None
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
try:
await self.coordinator.api.async_open_device(self.device_id)
except FlussApiClientError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="command_failed"
) from err
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
try:
await self.coordinator.api.async_close_device(self.device_id)
except FlussApiClientError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="command_failed"
) from err
await self.coordinator.async_request_refresh()
@@ -19,10 +19,5 @@
"description": "Your Fluss API key, available in the profile page of the Fluss+ app"
}
}
},
"exceptions": {
"command_failed": {
"message": "Failed to send command to Fluss+ device"
}
}
}
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
super()._handle_coordinator_update()
return
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
time = datetime.now(UTC) + timedelta(seconds=value)
if not self._attr_native_value:
self._attr_native_value = time
super()._handle_coordinator_update()
@@ -199,7 +199,6 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER,
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER,
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV,
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV,
(sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR,
(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR,
(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR,
@@ -2728,11 +2728,7 @@ class ChannelTrait(_Trait):
if (
domain == media_player.DOMAIN
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
and device_class
in (
media_player.MediaPlayerDeviceClass.TV,
media_player.MediaPlayerDeviceClass.PROJECTOR,
)
and device_class == media_player.MediaPlayerDeviceClass.TV
):
return True
@@ -37,7 +37,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription):
def _get_lowest_price_day_time(
api: GreenPlanetEnergyAPI, data: dict[str, Any]
) -> datetime | None:
"""Return timestamp of the lowest-priced day hour (06:00-18:00)."""
"""Return timestamp of the lowest-priced day hour (06:0018:00)."""
now = dt_util.now()
now_h = now.hour
hour = api.get_lowest_price_day_with_hour(data, now_h)[1]
@@ -10,8 +10,8 @@ Classic API (username/password):
Open API V1 (API token):
- Stateless no login call, token is sent as a Bearer header on every request.
- Auth failure is signalled by raising GrowattV1ApiError with
error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE. The library NEVER returns a failure silently;
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
any non-zero error_code raises an exception via _process_response().
- Because the library always raises on error, return-value validation after a
successful V1 API call is unnecessary if it returned, the token was valid.
@@ -19,7 +19,7 @@ Open API V1 (API token):
Error handling pattern for reauth:
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
raise ConfigEntryAuthFailed
- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
raise ConfigEntryAuthFailed
- All other errors ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
@@ -30,7 +30,6 @@ from json import JSONDecodeError
import logging
import growattServer
from growattServer import GrowattV1ApiErrorCode
from requests import RequestException
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
@@ -59,6 +58,8 @@ from .const import (
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
SUPPORTED_DEVICE_TYPES,
V1_API_ERROR_NO_PRIVILEGE,
V1_API_ERROR_RATE_LIMITED,
V1_DEVICE_TYPES,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
@@ -238,24 +239,15 @@ def _login_classic_api(
login_response = api.login(username, password)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(ex)},
f"Error communicating with Growatt API during login: {ex}"
) from ex
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
_LOGGER.debug("Growatt login failed: %s", msg)
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
)
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
return login_response
@@ -273,25 +265,17 @@ def get_device_list_v1(
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": e.error_msg or str(e)},
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
) from e
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
if e.error_code == V1_API_ERROR_RATE_LIMITED:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="rate_limited",
translation_placeholders={"error": e.error_msg or str(e)},
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
) from e
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error_with_code",
translation_placeholders={
"error": e.error_msg or str(e),
"code": str(e.error_code),
},
f"API error during device list: {e.error_msg or str(e)}"
f" (Code: {e.error_code})"
) from e
devices = devices_dict.get("devices", [])
supported_devices = [
@@ -365,15 +349,10 @@ async def async_setup_entry(
devices = await hass.async_add_executor_job(api.device_list, plant_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(ex)},
f"Error communicating with Growatt API during device list: {ex}"
) from ex
else:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unknown_auth_type",
)
raise ConfigEntryError("Unknown authentication type in config entry.")
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -5,7 +5,6 @@ import logging
from typing import Any
import growattServer
from growattServer import GrowattV1ApiErrorCode
import requests
import voluptuous as vol
@@ -33,6 +32,7 @@ from .const import (
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
V1_API_ERROR_NO_PRIVILEGE,
)
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
@@ -148,7 +148,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Network error during credential update: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
errors["base"] = ERROR_INVALID_AUTH
else:
_LOGGER.debug(
@@ -301,7 +301,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
e.error_msg or str(e),
e.error_code,
)
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
@@ -42,6 +42,13 @@ PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
# Growatt Classic API error codes
LOGIN_INVALID_AUTH_CODE = "502"
# Growatt Open API V1 error codes
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
@@ -6,7 +6,6 @@ import logging
from typing import TYPE_CHECKING, Any
import growattServer
from growattServer import GrowattV1ApiErrorCode
from requests import RequestException
from homeassistant.components.sensor import SensorStateClass
@@ -28,6 +27,7 @@ from .const import (
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .models import GrowattRuntimeData
@@ -113,11 +113,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if device.get("type") in V1_DEVICE_TYPES
]
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
self.device_list = None
@@ -159,14 +157,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
"Username, password, or URL may be incorrect"
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
raise UpdateFailed(f"Growatt login failed: {msg}")
if self.device_type == "total":
if self.api_version == "v1":
@@ -186,18 +179,13 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
total_info = self.api.plant_energy_overview(self.plant_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={
"error": err.error_msg or str(err)
},
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
f"Error fetching plant energy overview: {err}"
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
@@ -224,17 +212,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
@@ -257,17 +240,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
sph_detail = self.api.sph_detail(self.device_id)
sph_energy = self.api.sph_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
combined = {**sph_detail, **sph_energy}
@@ -335,11 +313,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
return await self.hass.async_add_executor_job(self._sync_update_data)
except json.decoder.JSONDecodeError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
raise UpdateFailed(f"Error fetching data: {err}") from err
def request_device_list_scan(self) -> None:
"""Request that the next _sync_update_data also fetches the device list.
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "gold",
"quality_scale": "silver",
"requirements": ["growattServer==2.1.0"]
}
@@ -56,7 +56,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -595,15 +595,6 @@
"api_error": {
"message": "Growatt API error: {error}"
},
"api_error_with_code": {
"message": "API error: {error} (Code: {code})"
},
"auth_failed": {
"message": "Authentication failed for Growatt API: {error}"
},
"communication_error": {
"message": "Error communicating with Growatt API: {error}"
},
"device_not_configured": {
"message": "{device_type} device {serial_number} is not configured for actions."
},
@@ -613,9 +604,6 @@
"device_not_growatt": {
"message": "Device {device_id} is not a Growatt device."
},
"fetch_data_failed": {
"message": "Error fetching data from Growatt API: {error}"
},
"invalid_batt_mode": {
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
},
@@ -625,9 +613,6 @@
"invalid_charge_stop_soc": {
"message": "'Charge stop SOC' must be between 0 and 100, got {value}."
},
"invalid_credentials": {
"message": "Username, password, or URL may be incorrect"
},
"invalid_discharge_power": {
"message": "'Discharge power' must be between 0 and 100, got {value}."
},
@@ -649,20 +634,11 @@
"invalid_time_format_start_time": {
"message": "'Start time' must be in HH:MM or HH:MM:SS format."
},
"login_failed": {
"message": "Growatt login failed: {message}"
},
"no_devices_configured": {
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
},
"rate_limited": {
"message": "Growatt API rate limited, will retry: {error}"
},
"token_auth_required": {
"message": "This action requires token authentication (V1 API)."
},
"unknown_auth_type": {
"message": "Unknown authentication type in config entry"
}
},
"selector": {
@@ -1,26 +0,0 @@
"""The Helty Flow integration."""
from pyhelty import HeltyClient
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
"""Set up Helty Flow from a config entry."""
client = HeltyClient(entry.data[CONF_HOST])
coordinator = HeltyDataUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,40 +0,0 @@
"""Config flow for the Helty Flow integration."""
from typing import Any
from pyhelty import HeltyClient, HeltyConnectionError, HeltyError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
class HeltyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Helty Flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial setup step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
client = HeltyClient(user_input[CONF_HOST])
try:
name = await client.async_get_name()
except HeltyConnectionError:
errors["base"] = "cannot_connect"
except HeltyError:
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=name or user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
-13
View File
@@ -1,13 +0,0 @@
"""Constants for the Helty Flow integration."""
from datetime import timedelta
DOMAIN = "helty"
#: How often the coordinator polls the unit.
SCAN_INTERVAL = timedelta(seconds=60)
# Fan preset mode identifiers (also used as translation keys).
PRESET_BOOST = "boost"
PRESET_NIGHT = "night"
PRESET_FREE_COOLING = "free_cooling"
@@ -1,45 +0,0 @@
"""DataUpdateCoordinator for the Helty Flow integration."""
import logging
from pyhelty import HeltyClient, HeltyConnectionError, HeltyData, HeltyError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type HeltyConfigEntry = ConfigEntry[HeltyDataUpdateCoordinator]
class HeltyDataUpdateCoordinator(DataUpdateCoordinator[HeltyData]):
"""Coordinate a single poll of the Helty unit for all entities."""
config_entry: HeltyConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: HeltyConfigEntry,
client: HeltyClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
async def _async_update_data(self) -> HeltyData:
try:
return await self.client.async_get_data()
except HeltyConnectionError as err:
raise UpdateFailed(f"Error communicating with Helty unit: {err}") from err
except HeltyError as err:
raise UpdateFailed(f"Unexpected response from Helty unit: {err}") from err
-25
View File
@@ -1,25 +0,0 @@
"""Base entity for the Helty Flow integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import HeltyDataUpdateCoordinator
class HeltyEntity(CoordinatorEntity[HeltyDataUpdateCoordinator]):
"""Common base for Helty entities sharing one device and coordinator."""
_attr_has_entity_name = True
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
"""Initialize the entity and its shared device info."""
super().__init__(coordinator)
# The unit exposes no serial/MAC, so the config entry id identifies it.
self._device_id = coordinator.config_entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name=coordinator.data.name,
manufacturer="Helty",
model="Flow",
)
-120
View File
@@ -1,120 +0,0 @@
"""Fan platform for the Helty Flow integration."""
from typing import Any
from pyhelty import FanMode
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
from .entity import HeltyEntity
PARALLEL_UPDATES = 1
# Ordered list of discrete fan speeds, lowest to highest.
ORDERED_SPEEDS: list[FanMode] = [
FanMode.LOW,
FanMode.MEDIUM,
FanMode.HIGH,
FanMode.MAX,
]
PRESET_TO_MODE: dict[str, FanMode] = {
PRESET_BOOST: FanMode.BOOST,
PRESET_NIGHT: FanMode.NIGHT,
PRESET_FREE_COOLING: FanMode.FREE_COOLING,
}
MODE_TO_PRESET: dict[FanMode, str] = {
mode: preset for preset, mode in PRESET_TO_MODE.items()
}
async def async_setup_entry(
hass: HomeAssistant,
entry: HeltyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Helty fan."""
async_add_entities([HeltyFan(entry.runtime_data)])
class HeltyFan(HeltyEntity, FanEntity):
"""The ventilation unit's fan, the device's primary feature."""
_attr_name = None
_attr_speed_count = len(ORDERED_SPEEDS)
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
"""Initialize the fan."""
super().__init__(coordinator)
self._attr_unique_id = self._device_id
self._attr_preset_modes = list(PRESET_TO_MODE)
@property
def _mode(self) -> FanMode:
return self.coordinator.data.fan_mode
@property
def is_on(self) -> bool:
"""Return whether the fan is running."""
return self._mode is not FanMode.OFF
@property
def percentage(self) -> int | None:
"""Return the current speed as a percentage, or None when on a preset."""
if self._mode in ORDERED_SPEEDS:
return ordered_list_item_to_percentage(ORDERED_SPEEDS, self._mode)
return None
@property
def preset_mode(self) -> str | None:
"""Return the active preset, or None when running on a discrete speed."""
return MODE_TO_PRESET.get(self._mode)
async def async_set_percentage(self, percentage: int) -> None:
"""Set a discrete fan speed from a percentage."""
if percentage == 0:
await self._async_set_mode(FanMode.OFF)
return
await self._async_set_mode(
percentage_to_ordered_list_item(ORDERED_SPEEDS, percentage)
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set a preset mode."""
await self._async_set_mode(PRESET_TO_MODE[preset_mode])
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on."""
if preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
elif percentage is not None:
await self.async_set_percentage(percentage)
else:
await self._async_set_mode(FanMode.LOW)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._async_set_mode(FanMode.OFF)
async def _async_set_mode(self, mode: FanMode) -> None:
await self.coordinator.client.async_set_fan_mode(mode)
await self.coordinator.async_request_refresh()
@@ -1,12 +0,0 @@
{
"domain": "helty",
"name": "Helty Flow",
"codeowners": ["@ebaschiera"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/helty",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyhelty"],
"quality_scale": "bronze",
"requirements": ["pyhelty==0.2.0"]
}
@@ -1,90 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no options to configure.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The device does not support discovery.
discovery:
status: exempt
comment: |
The device exposes no discovery protocol (no mDNS/SSDP) and no stable
identifier such as a serial number or MAC over its interface.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: A config entry represents a single fixed device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
comment: The fan entity uses the default fan icon.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration has no repairable issues to surface.
stale-devices:
status: exempt
comment: A config entry represents a single fixed device.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
The device is controlled over a raw TCP socket, not HTTP, so there is no
web session to inject.
strict-typing: todo
-87
View File
@@ -1,87 +0,0 @@
"""Sensor platform for the Helty Flow integration."""
from collections.abc import Callable
from dataclasses import dataclass
from pyhelty import HeltyData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
from .entity import HeltyEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class HeltySensorEntityDescription(SensorEntityDescription):
"""Describes a Helty sensor."""
value_fn: Callable[[HeltyData], float | None]
SENSORS: tuple[HeltySensorEntityDescription, ...] = (
HeltySensorEntityDescription(
key="indoor_temperature",
translation_key="indoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.indoor_temperature,
),
HeltySensorEntityDescription(
key="outdoor_temperature",
translation_key="outdoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.outdoor_temperature,
),
HeltySensorEntityDescription(
key="indoor_humidity",
translation_key="indoor_humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.indoor_humidity,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HeltyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Helty sensors."""
coordinator = entry.runtime_data
async_add_entities(HeltySensor(coordinator, description) for description in SENSORS)
class HeltySensor(HeltyEntity, SensorEntity):
"""An environmental sensor reported by the ventilation unit."""
entity_description: HeltySensorEntityDescription
def __init__(
self,
coordinator: HeltyDataUpdateCoordinator,
description: HeltySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{self._device_id}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the current sensor reading."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -1,35 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address or hostname of the Helty Flow unit on your network."
},
"title": "Connect to your Helty Flow"
}
}
},
"entity": {
"sensor": {
"indoor_humidity": {
"name": "Indoor humidity"
},
"indoor_temperature": {
"name": "Indoor temperature"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
}
}
}
}
+13 -34
View File
@@ -1,7 +1,7 @@
"""The homee cover platform."""
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import Any, cast
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute, HomeeNode
@@ -35,12 +35,6 @@ COVER_DEVICE_PROFILES = {
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
IS_CLOSED_ATTRIBUTES = [
AttributeType.OPEN_CLOSE,
AttributeType.UP_DOWN,
AttributeType.POSITION,
AttributeType.SHUTTER_SLAT_POSITION,
]
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
@@ -89,23 +83,9 @@ async def add_cover_entities(
nodes: list[HomeeNode],
) -> None:
"""Add homee cover entities."""
entities: list[HomeeNode] = []
for node in nodes:
if is_cover_node(node):
if any(
node.get_attribute_by_type(attr) is not None
for attr in IS_CLOSED_ATTRIBUTES
):
entities.append(node)
else:
_LOGGER.warning(
"Cover %s could not be added, because it is missing an Attribute "
"for closed indication. Please open an issue at "
"https://github.com/home-assistant/core/issues",
node.name,
)
async_add_entities(HomeeCover(cover, config_entry) for cover in entities)
async_add_entities(
HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
)
async def async_setup_entry(
@@ -207,7 +187,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
return None
@property
def is_closed(self) -> bool:
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
if (
attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
@@ -220,16 +200,15 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
return self._open_close_attribute.get_value() == 0
# If none of the above is present, it will be a slat only cover.
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
if TYPE_CHECKING:
# This case should not happen, because we check for
# the presence of an IS_CLOSED_ATTRIBUTE when adding entities.
assert attribute is not None
# If none of the above is present, it might be a slat only cover.
if (
attribute := self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
) is not None:
return attribute.get_value() == attribute.minimum
return attribute.get_value() == attribute.minimum
return None
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
@@ -202,10 +202,7 @@ def get_accessory( # noqa: C901
if device_class == MediaPlayerDeviceClass.RECEIVER:
a_type = "ReceiverMediaPlayer"
elif device_class in (
MediaPlayerDeviceClass.TV,
MediaPlayerDeviceClass.PROJECTOR,
):
elif device_class == MediaPlayerDeviceClass.TV:
a_type = "TelevisionMediaPlayer"
elif validate_media_player_features(state, feature_list):
a_type = "MediaPlayer"
+1 -5
View File
@@ -695,11 +695,7 @@ def state_needs_accessory_mode(state: State) -> bool:
return (
state.domain == MEDIA_PLAYER_DOMAIN
and state.attributes.get(ATTR_DEVICE_CLASS)
in (
MediaPlayerDeviceClass.TV,
MediaPlayerDeviceClass.RECEIVER,
MediaPlayerDeviceClass.PROJECTOR,
)
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
) or (
state.domain == REMOTE_DOMAIN
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -6,14 +6,7 @@ import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.exceptions import GTIError
from pygti.models import (
ElevatorState,
SDName,
SDNameType,
StationInformationRequest,
StationInformationResponse,
)
from pygti.exceptions import InvalidAuth
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -45,21 +38,20 @@ async def async_setup_entry(
station = entry.data[CONF_STATION]
def get_elevator_entities_from_station_information(
station_name: str,
station_information: StationInformationResponse | None,
) -> dict[str, Any]:
station_name, station_information
):
"""Convert station information into a list of elevators."""
elevators = {}
if station_information is None:
return {}
for partial_station in station_information.partialStations or []:
for elevator in partial_station.elevators or []:
state = elevator.state != ElevatorState.READY
available = elevator.state != ElevatorState.UNKNOWN
label = elevator.label
description = elevator.description
for partial_station in station_information.get("partialStations", []):
for elevator in partial_station.get("elevators", []):
state = elevator.get("state") != "READY"
available = elevator.get("state") != "UNKNOWN"
label = elevator.get("label")
description = elevator.get("description")
if label is not None:
name = f"Elevator {label}"
@@ -69,7 +61,7 @@ async def async_setup_entry(
if description is not None:
name += f" ({description})"
lines = elevator.lines
lines = elevator.get("lines")
idx = f"{station_name}-{label}-{lines}"
@@ -78,35 +70,33 @@ async def async_setup_entry(
"name": name,
"available": available,
"attributes": {
"cabin_width": elevator.cabinWidth,
"cabin_length": elevator.cabinLength,
"door_width": elevator.doorWidth,
"elevator_type": elevator.elevatorType,
"button_type": elevator.buttonType,
"cause": elevator.cause,
"cabin_width": elevator.get("cabinWidth"),
"cabin_length": elevator.get("cabinLength"),
"door_width": elevator.get("doorWidth"),
"elevator_type": elevator.get("elevatorType"),
"button_type": elevator.get("buttonType"),
"cause": elevator.get("cause"),
"lines": lines,
},
}
return elevators
async def async_update_data() -> dict[str, Any]:
async def async_update_data():
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
payload = StationInformationRequest(
station=SDName(id=station["id"], type=SDNameType(station["type"]))
)
payload = {"station": {"id": station["id"], "type": station["type"]}}
try:
async with asyncio.timeout(10):
return get_elevator_entities_from_station_information(
station_name, await hub.gti.getStationInformation(payload)
station_name, await hub.gti.stationInformation(payload)
)
except GTIError as err:
raise UpdateFailed(f"GTI API error: {err}") from err
except InvalidAuth as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
except ClientConnectorError as err:
raise UpdateFailed(f"Network not available: {err}") from err
except Exception as err:
@@ -139,12 +129,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(
self,
coordinator: DataUpdateCoordinator[dict[str, Any]],
idx: str,
config_entry: HVVConfigEntry,
) -> None:
def __init__(self, coordinator, idx, config_entry):
"""Initialize."""
super().__init__(coordinator)
self.coordinator = coordinator
@@ -155,7 +140,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
( # type: ignore[arg-type]
(
DOMAIN,
config_entry.entry_id,
config_entry.data[CONF_STATION]["id"],
@@ -169,7 +154,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return entity state."""
return bool(self.coordinator.data[self.idx]["state"])
return self.coordinator.data[self.idx]["state"]
@property
def available(self) -> bool:
@@ -3,17 +3,8 @@
import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.auth import GTI_DEFAULT_HOST
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import (
CNRequest,
DLRequest,
GTITime,
RegionalSDNameType,
SDName,
SDNameType,
)
from pygti.exceptions import CannotConnect, InvalidAuth
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -75,10 +66,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
try:
response = await self.hub.authenticate()
_LOGGER.debug("Init gti: %r", response)
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
except GTIError, ClientConnectorError:
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
if not errors:
self.data = user_input
@@ -96,14 +87,15 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
check_name = await self.hub.gti.checkName(
CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20)
{"theName": {"name": user_input[CONF_STATION]}, "maxList": 20}
)
stations = check_name.get("results")
self.stations = {
station.name: station
for station in (check_name.results or [])
if station.type == RegionalSDNameType.STATION
and station.name is not None
f"{station.get('name')}": station
for station in stations
if station.get("type") == "STATION"
}
if not self.stations:
@@ -129,13 +121,7 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="station_select", data_schema=schema)
self.data.update(
{
"station": self.stations[user_input[CONF_STATION]].model_dump(
mode="json", exclude_none=True
)
}
)
self.data.update({"station": self.stations[user_input[CONF_STATION]]})
title = self.data[CONF_STATION]["name"]
@@ -165,30 +151,32 @@ class OptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors = {}
if not self.departure_filters:
departure_list = {}
hub = self.config_entry.runtime_data
try:
departure_list = await hub.gti.departureList(
DLRequest(
station=SDName(
id=self.config_entry.data[CONF_STATION].get("id"),
type=SDNameType.STATION,
),
time=GTITime(date="heute", time="jetzt"),
maxList=5,
maxTimeOffset=200,
useRealtime=True,
returnFilters=True,
)
{
"station": {
"type": "STATION",
"id": self.config_entry.data[CONF_STATION].get("id"),
},
"time": {"date": "heute", "time": "jetzt"},
"maxList": 5,
"maxTimeOffset": 200,
"useRealtime": True,
"returnFilters": True,
}
)
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
except GTIError, ClientConnectorError:
except CannotConnect:
errors["base"] = "cannot_connect"
else:
except InvalidAuth:
errors["base"] = "invalid_auth"
if not errors:
self.departure_filters = {
str(i): f.model_dump(mode="json", exclude_none=True)
for i, f in enumerate(departure_list.filter or [])
str(i): departure_filter
for i, departure_filter in enumerate(departure_list["filter"])
}
if user_input is not None and not errors:
@@ -218,8 +206,8 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select(
{
key: (
f"{departure_filter.get('serviceName', '')},"
f" {departure_filter.get('label', '')}"
f"{departure_filter['serviceName']},"
f" {departure_filter['label']}"
)
for key, departure_filter in self.departure_filters.items()
}
@@ -1,8 +1,6 @@
"""Hub."""
from aiohttp import ClientSession
from pygti.gti import GTI, Auth
from pygti.models import InitRequest, InitResponse
from homeassistant.config_entries import ConfigEntry
@@ -12,9 +10,7 @@ type HVVConfigEntry = ConfigEntry[GTIHub]
class GTIHub:
"""GTI Hub."""
def __init__(
self, host: str, username: str, password: str, session: ClientSession
) -> None:
def __init__(self, host, username, password, session):
"""Initialize."""
self.host = host
self.username = username
@@ -22,7 +18,7 @@ class GTIHub:
self.gti = GTI(Auth(session, self.username, self.password, self.host))
async def authenticate(self) -> InitResponse:
async def authenticate(self):
"""Test if we can authenticate with the host."""
return await self.gti.init(InitRequest())
return await self.gti.init()
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pygti"],
"requirements": ["pygti==1.1.1"]
"requirements": ["pygti==0.9.4"]
}
@@ -4,9 +4,8 @@ from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectorError, ClientSession
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import DLRequest, GTITime, SDName, SDNameType
from aiohttp import ClientConnectorError
from pygti.exceptions import InvalidAuth
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import ATTR_ID, CONF_OFFSET
@@ -17,15 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from homeassistant.util.dt import get_time_zone, utcnow
from .const import (
ATTRIBUTION,
CONF_FILTER,
CONF_REAL_TIME,
CONF_STATION,
DOMAIN,
MANUFACTURER,
)
from .hub import GTIHub, HVVConfigEntry
from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER
from .hub import HVVConfigEntry
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
MAX_LIST = 20
@@ -70,17 +62,11 @@ class HVVDepartureSensor(SensorEntity):
_attr_has_entity_name = True
_attr_available = False
def __init__(
self,
hass: HomeAssistant,
config_entry: HVVConfigEntry,
session: ClientSession,
hub: GTIHub,
) -> None:
def __init__(self, hass, config_entry, session, hub):
"""Initialize."""
self.config_entry = config_entry
self.station_name = self.config_entry.data[CONF_STATION]["name"]
self._last_error: type[Exception] | Exception | None = None
self._last_error = None
self._attr_extra_state_attributes = {}
self.gti = hub.gti
@@ -91,7 +77,7 @@ class HVVDepartureSensor(SensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
( # type: ignore[arg-type]
(
DOMAIN,
config_entry.entry_id,
config_entry.data[CONF_STATION]["id"],
@@ -113,46 +99,39 @@ class HVVDepartureSensor(SensorEntity):
station = self.config_entry.data[CONF_STATION]
request = DLRequest(
station=SDName(id=station["id"], type=SDNameType(station["type"])),
time=GTITime(
date=departure_time_tz_berlin.strftime("%d.%m.%Y"),
time=departure_time_tz_berlin.strftime("%H:%M"),
),
maxList=MAX_LIST,
maxTimeOffset=MAX_TIME_OFFSET,
useRealtime=self.config_entry.options.get(CONF_REAL_TIME, False),
filter=self.config_entry.options.get(CONF_FILTER),
)
payload = {
"station": {"id": station["id"], "type": station["type"]},
"time": {
"date": departure_time_tz_berlin.strftime("%d.%m.%Y"),
"time": departure_time_tz_berlin.strftime("%H:%M"),
},
"maxList": MAX_LIST,
"maxTimeOffset": MAX_TIME_OFFSET,
"useRealtime": self.config_entry.options.get(CONF_REAL_TIME, False),
}
if "filter" in self.config_entry.options:
payload.update({"filter": self.config_entry.options["filter"]})
try:
data = await self.gti.departureList(request)
except GTIUnauthorizedError as error:
if self._last_error != GTIUnauthorizedError:
data = await self.gti.departureList(payload)
except InvalidAuth as error:
if self._last_error != InvalidAuth:
_LOGGER.error("Authentication failed: %r", error)
self._last_error = GTIUnauthorizedError
self._last_error = InvalidAuth
self._attr_available = False
return
except GTIError as error:
if self._last_error != GTIError:
_LOGGER.warning("GTI API error: %r", error)
self._last_error = GTIError
self._attr_available = False
return
except ClientConnectorError as error:
if self._last_error != ClientConnectorError:
_LOGGER.warning("Network unavailable: %r", error)
self._last_error = ClientConnectorError
self._attr_available = False
return
except Exception as error: # noqa: BLE001
if self._last_error != error:
_LOGGER.error("Error occurred while fetching data: %r", error)
self._last_error = error
self._attr_available = False
return
if not data.departures:
if not (data["returnCode"] == "OK" and data.get("departures")):
self._attr_available = False
return
@@ -161,27 +140,25 @@ class HVVDepartureSensor(SensorEntity):
self._last_error = None
departure = data.departures[0]
line = departure.line
delay = departure.delay if departure.delay is not None else 0
cancelled = departure.cancelled if departure.cancelled is not None else False
extra = departure.extra if departure.extra is not None else False
departure = data["departures"][0]
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
self._attr_available = True
self._attr_native_value = (
departure_time
+ timedelta(
minutes=departure.timeOffset if departure.timeOffset is not None else 0
)
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(seconds=delay)
)
self._attr_extra_state_attributes.update(
{
ATTR_LINE: line.name,
ATTR_ORIGIN: line.origin,
ATTR_DIRECTION: line.direction,
ATTR_TYPE: line.type.shortInfo,
ATTR_ID: line.id,
ATTR_LINE: line["name"],
ATTR_ORIGIN: line["origin"],
ATTR_DIRECTION: line["direction"],
ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"],
ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
@@ -189,27 +166,21 @@ class HVVDepartureSensor(SensorEntity):
)
departures = []
for departure in data.departures:
line = departure.line
delay = departure.delay if departure.delay is not None else 0
cancelled = (
departure.cancelled if departure.cancelled is not None else False
)
extra = departure.extra if departure.extra is not None else False
for departure in data["departures"]:
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
departures.append(
{
ATTR_DEPARTURE: departure_time
+ timedelta(
minutes=departure.timeOffset
if departure.timeOffset is not None
else 0
)
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(seconds=delay),
ATTR_LINE: line.name,
ATTR_ORIGIN: line.origin,
ATTR_DIRECTION: line.direction,
ATTR_TYPE: line.type.shortInfo,
ATTR_ID: line.id,
ATTR_LINE: line["name"],
ATTR_ORIGIN: line["origin"],
ATTR_DIRECTION: line["direction"],
ATTR_TYPE: line["type"]["shortInfo"],
ATTR_ID: line["id"],
ATTR_DELAY: delay,
ATTR_CANCELLED: cancelled,
ATTR_EXTRA: extra,
-42
View File
@@ -1,42 +0,0 @@
"""Support for Imou devices."""
from pyimouapi.device import ImouDeviceManager
from pyimouapi.ha_device import ImouHaDeviceManager
from pyimouapi.openapi import ImouOpenApiClient
from homeassistant.core import HomeAssistant, callback
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Set up Imou integration from a config entry."""
imou_client = ImouOpenApiClient(
entry.data[CONF_APP_ID],
entry.data[CONF_APP_SECRET],
API_URLS[entry.data[CONF_API_URL]],
)
device_manager = ImouDeviceManager(imou_client)
imou_device_manager = ImouHaDeviceManager(device_manager)
imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry)
await imou_coordinator.async_config_entry_first_refresh()
entry.runtime_data = imou_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# DataUpdateCoordinator schedules periodic refreshes only when it has
# listeners. With zero entities (e.g. an empty account at setup), register a
# no-op listener so polling continues and later devices are discovered via
# new_device_callbacks.
@callback
def _async_keep_polling() -> None:
"""Keep periodic polling when no entities are registered yet."""
entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Handle removal of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-109
View File
@@ -1,109 +0,0 @@
"""Support for Imou button controls."""
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
from .entity import ImouEntity
PARALLEL_UPDATES = 1
# Button types
PARAM_RESTART_DEVICE = "restart_device"
PARAM_MUTE = "mute"
PARAM_PTZ_UP = "ptz_up"
PARAM_PTZ_DOWN = "ptz_down"
PARAM_PTZ_LEFT = "ptz_left"
PARAM_PTZ_RIGHT = "ptz_right"
BUTTON_TYPES = (
PARAM_RESTART_DEVICE,
PARAM_MUTE,
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)
PTZ_BUTTON_TYPES = (
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)
BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = {
PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART,
}
def _iter_buttons(
coordinator: ImouDataUpdateCoordinator,
) -> list[tuple[str, ImouHaDevice]]:
"""Return (button_type, device) pairs for supported buttons."""
return [
(button_type, device)
for device in coordinator.devices
for button_type in device.buttons
if button_type in BUTTON_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ImouConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Imou button entities."""
coordinator = entry.runtime_data
def _add_buttons(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
ImouButton(coordinator, button_type, device)
for button_type, device in _iter_buttons(coordinator)
if imou_device_identifier(device) in device_keys
)
coordinator.new_device_callbacks.append(_add_buttons)
@callback
def _remove_new_device_callback() -> None:
if _add_buttons in coordinator.new_device_callbacks:
coordinator.new_device_callbacks.remove(_add_buttons)
entry.async_on_unload(_remove_new_device_callback)
_add_buttons(coordinator.devices)
class ImouButton(ImouEntity, ButtonEntity):
"""Imou button entity."""
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou button entity."""
super().__init__(coordinator, entity_type, device)
if device_class := BUTTON_DEVICE_CLASS.get(entity_type):
self._attr_device_class = device_class
self._attr_translation_key = None
async def async_press(self) -> None:
"""Handle button press."""
duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0
try:
await self.coordinator.device_manager.async_press_button(
self.device,
self._entity_type,
duration,
)
except ImouException as e:
raise HomeAssistantError(str(e)) from e
@@ -1,80 +0,0 @@
"""Config flow for Imou."""
import logging
from typing import Any
from pyimouapi.exceptions import (
ConnectFailedException,
ImouException,
InvalidAppIdOrSecretException,
RequestFailedException,
)
from pyimouapi.openapi import ImouOpenApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN
_LOGGER = logging.getLogger(__name__)
class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Imou integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_APP_ID])
self._abort_if_unique_id_configured()
api_client = ImouOpenApiClient(
user_input[CONF_APP_ID],
user_input[CONF_APP_SECRET],
API_URLS[user_input[CONF_API_URL]],
)
try:
await api_client.async_get_token()
except InvalidAppIdOrSecretException:
errors["base"] = "invalid_auth"
except ConnectFailedException, RequestFailedException:
errors["base"] = "cannot_connect"
except ImouException as exception:
_LOGGER.debug("Imou error during config flow: %s", exception)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Imou",
data={
CONF_APP_ID: user_input[CONF_APP_ID],
CONF_APP_SECRET: user_input[CONF_APP_SECRET],
CONF_API_URL: user_input[CONF_API_URL],
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_APP_ID): str,
vol.Required(CONF_APP_SECRET): str,
vol.Required(CONF_API_URL, default="sg"): SelectSelector(
SelectSelectorConfig(
options=list(API_URLS),
translation_key="api_url",
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
-39
View File
@@ -1,39 +0,0 @@
"""Constants."""
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.const import Platform
DOMAIN = "imou"
def imou_device_identifier(device: ImouHaDevice) -> str:
"""Return a device registry identifier (device_id + channel when present)."""
if device.channel_id is not None:
return f"{device.device_id}_{device.channel_id}"
return device.device_id
# API URL region mapping
API_URLS: dict[str, str] = {
"sg": "openapi-sg.easy4ip.com",
"eu": "openapi-or.easy4ip.com",
"na": "openapi-fk.easy4ip.com",
"cn": "openapi.lechange.cn",
}
CONF_API_URL = "api_url"
CONF_APP_ID = "app_id"
CONF_APP_SECRET = "app_secret"
PARAM_STATUS = "status"
PARAM_STATE = "state"
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
PTZ_MOVE_DURATION_MS = 500
# Upper bound for a full coordinator refresh (device list + status for all devices).
UPDATE_TIMEOUT = 300
PLATFORMS = [Platform.BUTTON]
@@ -1,152 +0,0 @@
"""Provides the Imou DataUpdateCoordinator."""
import asyncio
from collections.abc import Callable
from datetime import timedelta
import logging
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_TIMEOUT, imou_device_identifier
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=120)
type ImouConfigEntry = ConfigEntry[ImouDataUpdateCoordinator]
class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Data update coordinator for Imou devices."""
config_entry: ImouConfigEntry
def __init__(
self,
hass: HomeAssistant,
device_manager: ImouHaDeviceManager,
config_entry: ImouConfigEntry,
) -> None:
"""Initialize the Imou data update coordinator."""
super().__init__(
hass,
_LOGGER,
name="ImouDataUpdateCoordinator",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
always_update=True,
)
self._device_manager = device_manager
self.devices_by_key: dict[str, ImouHaDevice] = {}
self._devices_initialized = False
self.new_device_callbacks: list[Callable[[list[ImouHaDevice]], None]] = []
@property
def devices(self) -> list[ImouHaDevice]:
"""Return the list of devices."""
return list(self.devices_by_key.values())
@property
def device_manager(self) -> ImouHaDeviceManager:
"""Return the device manager."""
return self._device_manager
def get_device(self, device_key: str) -> ImouHaDevice | None:
"""Return the current device for device_key, if still on the account."""
return self.devices_by_key.get(device_key)
async def _async_update_data(self) -> None:
"""Update coordinator data."""
try:
async with asyncio.timeout(UPDATE_TIMEOUT):
fresh_devices = await self._device_manager.async_get_devices()
except TimeoutError as err:
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
except ImouException as err:
raise UpdateFailed(f"Error fetching Imou devices: {err}") from err
fresh_by_key = {
imou_device_identifier(device): device for device in fresh_devices
}
self._async_add_remove_devices(fresh_by_key)
devices = list(self.devices_by_key.values())
if not devices:
return
try:
async with asyncio.timeout(UPDATE_TIMEOUT):
results = await asyncio.gather(
*(
self._device_manager.async_update_device_status(device)
for device in devices
),
return_exceptions=True,
)
except TimeoutError as err:
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
failures: list[Exception] = []
for device, result in zip(devices, results, strict=True):
if isinstance(result, BaseException) and not isinstance(result, Exception):
# Propagate CancelledError and other BaseExceptions instead of
# swallowing them as a regular device failure.
raise result
if not isinstance(result, Exception):
continue
device_key = imou_device_identifier(device)
_LOGGER.warning(
"Error updating status for Imou device %s: %s",
device_key,
result,
)
failures.append(result)
if failures and len(failures) == len(devices):
raise UpdateFailed(
f"Error updating Imou devices: {failures[0]}"
) from failures[0]
def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None:
"""Add new devices, remove devices no longer in the account.
This only tracks which devices exist on the account; per-device state
is updated in place by `async_update_device_status`, so devices that
remain on the account keep their existing object and are not replaced.
"""
if not self._devices_initialized:
self.devices_by_key = fresh_by_key
self._devices_initialized = True
return
current_keys = set(fresh_by_key)
known_keys = set(self.devices_by_key)
if current_keys == known_keys:
return
if removed_keys := known_keys - current_keys:
_LOGGER.debug("Removed Imou device(s): %s", ", ".join(removed_keys))
device_registry = dr.async_get(self.hass)
for device_key in removed_keys:
del self.devices_by_key[device_key]
if device := device_registry.async_get_device(
identifiers={(DOMAIN, device_key)}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
if new_keys := current_keys - known_keys:
_LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys))
new_devices = []
for device_key in new_keys:
self.devices_by_key[device_key] = fresh_by_key[device_key]
new_devices.append(fresh_by_key[device_key])
for callback in self.new_device_callbacks:
callback(new_devices)
-59
View File
@@ -1,59 +0,0 @@
"""An abstract class common to all Imou entities."""
from pyimouapi.ha_device import DeviceStatus, ImouHaDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier
from .coordinator import ImouDataUpdateCoordinator
class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]):
"""Base class for all Imou entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou entity."""
super().__init__(coordinator)
self._entity_type = entity_type
self._device_key = imou_device_identifier(device)
self._attr_unique_id = f"{self._device_key}${entity_type}"
self._attr_translation_key = entity_type
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_key)},
name=device.channel_name or device.device_name,
manufacturer=device.manufacturer,
model=device.model,
sw_version=device.swversion,
serial_number=device.device_id,
)
@property
def device(self) -> ImouHaDevice:
"""Return the live device from the coordinator.
Callers must guard with `available` first; accessing this for a device
that has left the account raises `KeyError`.
"""
return self.coordinator.devices_by_key[self._device_key]
@property
def available(self) -> bool:
"""Return if the entity is available."""
if (
not super().available
or self._device_key not in self.coordinator.devices_by_key
):
return False
if PARAM_STATUS not in self.device.sensors:
return False
return (
self.device.sensors[PARAM_STATUS][PARAM_STATE] != DeviceStatus.OFFLINE.value
)
-18
View File
@@ -1,18 +0,0 @@
{
"entity": {
"button": {
"ptz_down": {
"default": "mdi:arrow-down-bold"
},
"ptz_left": {
"default": "mdi:arrow-left-bold"
},
"ptz_right": {
"default": "mdi:arrow-right-bold"
},
"ptz_up": {
"default": "mdi:arrow-up-bold"
}
}
}
}
@@ -1,11 +0,0 @@
{
"domain": "imou",
"name": "Imou",
"codeowners": ["@Imou-OpenPlatform"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imou",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyimouapi==1.2.7"]
}
@@ -1,73 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Cloud service integration, does not support discovery.
discovery:
status: exempt
comment: >-
Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No
supported local discovery flow today; example cues if investigated later:
hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -1,56 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_url": "Server region",
"app_id": "App ID",
"app_secret": "App secret"
},
"data_description": {
"api_url": "Select the server region closest to your location",
"app_id": "The app ID obtained from the Imou cloud platform",
"app_secret": "The app secret obtained from the Imou cloud platform"
},
"title": "Log in to Imou cloud"
}
}
},
"entity": {
"button": {
"mute": {
"name": "Mute"
},
"ptz_down": {
"name": "PTZ down"
},
"ptz_left": {
"name": "PTZ left"
},
"ptz_right": {
"name": "PTZ right"
},
"ptz_up": {
"name": "PTZ up"
}
}
},
"selector": {
"api_url": {
"options": {
"cn": "China",
"eu": "Europe",
"na": "North America",
"sg": "Singapore (Asia-Pacific)"
}
}
}
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.8.1"]
"requirements": ["infrared-protocols==5.6.1"]
}
@@ -339,7 +339,6 @@ class IntegrationSensor(RestoreSensor):
else max_sub_interval
)
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time: datetime = datetime.now(tz=UTC)
self._last_integration_trigger = _IntegrationTrigger.StateEvent
self._attr_suggested_display_precision = round_digits or 2
@@ -499,7 +498,6 @@ class IntegrationSensor(RestoreSensor):
old_timestamp, new_timestamp, old_state, new_state
)
self._last_integration_trigger = _IntegrationTrigger.StateEvent
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time = datetime.now(tz=UTC)
finally:
# When max_sub_interval exceeds without state change the source is assumed
@@ -608,7 +606,6 @@ class IntegrationSensor(RestoreSensor):
self._update_integral(area)
self.async_write_ha_state()
# pylint: disable-next=home-assistant-enforce-utcnow
self._last_integration_time = datetime.now(tz=UTC)
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
+4 -4
View File
@@ -9,14 +9,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DATA_CONFIG, DOMAIN
from .const import DATA_CONFIG, IZONE
from .discovery import async_start_discovery_service, async_stop_discovery_service
PLATFORMS = [Platform.CLIMATE]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
IZONE: vol.Schema(
{
vol.Optional(CONF_EXCLUDE, default=[]): vol.All(
cv.ensure_list, [cv.string]
@@ -32,13 +32,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the iZone component config."""
# Check for manually added config, this may exclude some devices
if conf := config.get(DOMAIN):
if conf := config.get(IZONE):
hass.data[DATA_CONFIG] = conf
# Explicitly added in the config file, create a config entry.
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
IZONE, context={"source": config_entries.SOURCE_IMPORT}
)
)
+4 -4
View File
@@ -43,7 +43,7 @@ from .const import (
DISPATCH_CONTROLLER_RECONNECTED,
DISPATCH_CONTROLLER_UPDATE,
DISPATCH_ZONE_UPDATE,
DOMAIN,
IZONE,
)
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
@@ -188,7 +188,7 @@ class ControllerDevice(ClimateEntity):
self._attr_unique_id = controller.device_uid
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, controller.device_uid)},
identifiers={(IZONE, controller.device_uid)},
manufacturer="IZone",
model=controller.sys_type,
name=f"iZone Controller {controller.device_uid}",
@@ -484,12 +484,12 @@ class ZoneDevice(ClimateEntity):
assert controller.unique_id
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, controller.unique_id, zone.index) # type:ignore[arg-type]
(IZONE, controller.unique_id, zone.index) # type:ignore[arg-type]
},
manufacturer="IZone",
model=zone.type.name.title(),
name=zone.name.title(),
via_device=(DOMAIN, controller.unique_id),
via_device=(IZONE, controller.unique_id),
)
async def async_added_to_hass(self) -> None:
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_entry_flow
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY
from .const import DISPATCH_CONTROLLER_DISCOVERED, IZONE, TIMEOUT_DISCOVERY
from .discovery import async_start_discovery_service, async_stop_discovery_service
_LOGGER = logging.getLogger(__name__)
@@ -39,4 +39,4 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
return True
config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices)
config_entry_flow.register_discovery_flow(IZONE, "iZone Aircon", _async_has_devices)

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