Compare commits

...

59 Commits

Author SHA1 Message Date
Markus Tuominen 53211759cb Document missing pylint rules in plugin README (#172925) 2026-06-03 18:54:20 +03:00
Abílio Costa 4593059db2 Add "review" claude skill and use it in "gitbhub-pr-review" (#172797)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-03 15:57:21 +01:00
epenet 593ae9eb80 Add pylint plugin for correct use of DOMAIN constants in tests (#172693)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-03 16:53:15 +02:00
Erik Montnemery 37b4bcaa39 Don't log condition errors when executing WS test_condition (#172897) 2026-06-03 16:11:54 +02:00
Bram Kragten 6bda3ea3a5 Update frontend to 20260527.4 (#172907) 2026-06-03 14:17:30 +02:00
Sören f4db5fb346 Add Avea Bluetooth reachability diagnostics (#172898) 2026-06-03 13:43:33 +02:00
Heikki Henriksen f04b0ee2c6 prusalink: guard non-string original in config_flow workaround (#172375) 2026-06-03 11:47:19 +02:00
Erik Montnemery 52c3e17de9 Add zone occupancy conditions (#172896) 2026-06-03 11:20:13 +02:00
Imou-OpenPlatform 96c286f2e0 Add Imou integration (#161412)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-03 11:18:31 +02:00
renovate[bot] 3e356de4e1 Update pytest-asyncio to 1.4.0 (#172886) 2026-06-03 11:17:32 +02:00
Erik Montnemery 90a874d81b Use zone DOMAIN constant in zone triggers (#172894) 2026-06-03 11:15:50 +02:00
Erik Montnemery 165024c6c9 Catch errors when setting up condition in WS subscribe_condition (#172895) 2026-06-03 11:06:18 +02:00
Erik Montnemery 66e4db3c0e Add zone conditions in / not in zone (#172810) 2026-06-03 10:40:43 +02:00
Franck Nijhof 0e6128c657 Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) 2026-06-03 10:37:16 +02:00
Wendelin 16febb36ba Automation choose: Add optional note to options (#172837) 2026-06-03 10:12:52 +02:00
Erik Montnemery dd7bd0c8a4 Prevent log spam when WS subscribe_condition is active (#172832) 2026-06-03 10:10:30 +02:00
Petro31 c462a1c188 Add translations for template device trackers in_zones option (#172850) 2026-06-03 08:38:18 +02:00
fdebrus 96c5110b7e Vistapool: flip docs-related quality-scale rules to done (#172827)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 08:04:52 +02:00
Colin 64e8ed2737 Add missing translation keys to openevse (#172802) 2026-06-03 08:03:30 +02:00
renovate[bot] 4171d092f7 Update coverage to 7.14.1 (#172878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 07:59:49 +02:00
J. Nick Koston 7af867ad4d Avoid double-decoding websocket_api TEXT frames with decode_text (#172891) 2026-06-03 07:52:02 +02:00
Paulus Schoutsen 907fe40304 Regenerate mdi_icons.py for frontend 20260527.3 (#172887) 2026-06-03 05:56:21 +02:00
Petro31 261914c592 Use dt_util.utcnow() instead of datetime.now(UTC) in template tests (#172852) 2026-06-03 05:32:12 +02:00
David Bonnes 09637c1a3a Use dt_util.utcnow() instead of datetime.now(UTC) in evohome (#172868) 2026-06-03 05:31:07 +02:00
Bram Kragten 0816385185 Bump frontend to 20260527.3 (#172873) 2026-06-03 05:20:42 +02:00
renovate[bot] 4b64b26870 Update infrared-protocols to 5.8.1 (#172870) 2026-06-02 22:43:22 +01:00
Joost Lekkerkerker b20f9ad40a Bump pySmartThings to 4.0.0 (#172858) 2026-06-02 22:36:15 +02:00
Ronald van der Meer 99d279bdd8 Simplify Duco sensor tests (#172501) 2026-06-02 22:25:34 +02:00
jameson_uk 69e0e11077 Bump aioamazondevices to 14.0.0 (#172857) 2026-06-02 20:45:55 +01:00
Pete Sage 9e3c143bd0 Log warning on unsupported announce media formats for Sonos (#172614)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-02 21:27:54 +02:00
Rayman223 45c55543e9 Add EcoSmart resume schedule button to Wallbox (#171847)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:15:40 +02:00
Chris Caron fb02e93a0c Bump version of Apprise to v1.11.0 (#172622) 2026-06-02 21:10:15 +02:00
Marc Hörsken a54b97eeca Bump pywmspro to 0.4.0 for persistence support (#172193) 2026-06-02 21:09:32 +02:00
J. Nick Koston 61c196405b Bump aiohttp to 3.14.0 (#172838) 2026-06-02 21:05:57 +02:00
Tom Schneider 9a047ad115 Type hvv_departures integration (#172595) 2026-06-02 20:55:38 +02:00
Daniel Bergmann 07a584057c Add integration for the device Envertech EVT800 (#149456)
Co-authored-by: Dani <danigta2020@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-06-02 19:04:26 +02:00
Michael Hansen 5873dff1d9 Bump intents to 2026.6.1 (#172842) 2026-06-02 11:31:08 -05:00
Denys Karabetskyi 30a2bd9b92 Add button event entity to SwitchBot Contact Sensor. (#171876) 2026-06-02 17:48:58 +02:00
fdebrus 1065dce882 Add number platform to Vistapool (#172542)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-02 17:13:11 +02:00
johanzander 878a39194a Promote growatt_server to Gold quality scale (#171623)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 13:52:57 +02:00
Tom Schneider 2e2f4a7dcb Bump pygti to 1.1.1 (#172613) 2026-06-02 13:50:16 +02:00
Denis Shulyaka 46627984f8 Use homeassistant.util.dt.utcnow instead of datetime.now(UTC) in Anthropic (#172826) 2026-06-02 13:48:20 +02:00
Tomer 5445f9e42b Bump victron-mqtt to 2026.6.1 (#172676) 2026-06-02 13:33:02 +02:00
Ermanno Baschiera 8ce2a5257d Add Helty Flow temperature and humidity sensors (#172813)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:27:05 +02:00
bkobus-bbx 787828d7de Add reconfiguration flow for Blebox integration (#172569) 2026-06-02 13:26:38 +02:00
zhangluofeng 9e96912a1e Add xthings cloud switch (#172119)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 13:20:43 +02:00
zhangluofeng fd578cfd4c Don't create switch entity for switch device type in XThings Cloud (#172828) 2026-06-02 13:05:00 +02:00
Simon Lamon 94de8646c6 Modify stale policies for PRs and issues (#172812) 2026-06-02 12:52:49 +02:00
Sean Dague 2d19e84d15 Use arwn-client library in arwn (#172264) 2026-06-02 12:32:16 +02:00
Erik Montnemery a17cfbc2a5 Make the renamed trigger behavior options backwards compatible (#172822) 2026-06-02 12:31:57 +02:00
fdebrus c552b0a067 Vistapool: add DHCP discovery on SugarWIFI hostname (#172820)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 12:10:48 +02:00
Samuel Xiao 80241a44d9 Switchbot Cloud: Enable Webhook for sensor devices (#172814) 2026-06-02 11:02:08 +02:00
fdebrus d8b02ea6d6 Add button platform to Vistapool (#172550)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 10:21:16 +02:00
jameson_uk 36d2e85351 alexa devices - media player code quality (#172650) 2026-06-02 10:04:20 +02:00
epenet 174ac9eafe Deprecate single-use CONCENTRATION_PARTS_PER_CUBIC_METER constant (#172553) 2026-06-02 09:42:33 +02:00
Erik Montnemery 772c426d5d Add zone triggers occupancy detected/cleared (#172438) 2026-06-02 09:34:12 +02:00
Matthias Alphart a32d028e3d Update knx-frontend to 2026.6.1.213802 (#172806) 2026-06-02 08:04:20 +02:00
renovate[bot] bc66c2610e Update infrared-protocols to 5.8.0 (#172804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 05:44:29 +02:00
johanzander c22823ff8d Use growattServer library error code constants in growatt_server (#172771) 2026-06-02 04:54:27 +02:00
165 changed files with 9008 additions and 800 deletions
+2 -33
View File
@@ -8,39 +8,8 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.
3. Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## Verification:
- After the review, run parallel subagents for each finding to double check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions.
4. Check if all existing review comments have been addressed.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
- Suggest improvements where appropriate
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
```
- Make sure to include the file and line number when possible in the bullet points.
+38
View File
@@ -0,0 +1,38 @@
---
name: review
description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests.
---
# Review Code Changes
## Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
## Verification:
- After the review, run parallel subagents for each finding to double-check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
## IMPORTANT:
- Just review. DO NOT make any changes.
- Be constructive and specific in your comments.
- Suggest improvements where appropriate.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention.
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] sensor.py:143 - Memory leak
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
- [SUGGESTION] test_init.py:45 - Improve x variable name
```
- Make sure to include the file and line number when possible in the bullet points.
+24 -67
View File
@@ -22,21 +22,34 @@ jobs:
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
# - No issues (-1)
- name: 60 days stale PRs policy
# 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
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
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
repo-token: ${{ steps.token.outputs.token }}
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: >
@@ -49,65 +62,9 @@ jobs:
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# 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:
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 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
# issue policy
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted"
stale-issue-message: >
+1
View File
@@ -286,6 +286,7 @@ 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
+4
View File
@@ -501,6 +501,8 @@ 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
@@ -838,6 +840,8 @@ 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
@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_CUBIC_METER,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
@@ -47,6 +46,8 @@ from .coordinator import (
PARALLEL_UPDATES = 1
PARTS_PER_CUBIC_METER = "p/m³"
@dataclass(frozen=True, kw_only=True)
class AccuWeatherSensorDescription(SensorEntityDescription):
@@ -81,7 +82,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=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]]
@@ -107,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Mold",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=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]]
@@ -116,7 +117,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Ragweed",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
@@ -184,7 +185,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="Tree",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.2"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -1,8 +1,7 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from typing import Any
from aioamazondevices.structures import (
AmazonMediaControls,
@@ -38,18 +37,6 @@ STANDARD_SUPPORTED_FEATURES = (
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
@@ -69,9 +56,10 @@ async def async_setup_entry(
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
new_entities.append(
AlexaDevicesMediaPlayer(
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
)
)
if new_entities:
@@ -85,8 +73,6 @@ async def async_setup_entry(
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
description: MediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
@@ -214,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
return MediaType.MUSIC
return None
@@ -227,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
+2 -3
View File
@@ -4,7 +4,6 @@ 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
@@ -114,7 +113,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 slugify
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import (
@@ -372,7 +371,7 @@ def _convert_content( # noqa: C901
)
if (
content.native.container is not None
and content.native.container.expires_at > datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
and content.native.container.expires_at > dt_util.utcnow()
):
container_id = content.native.container.id
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["apprise"],
"quality_scale": "legacy",
"requirements": ["apprise==1.9.1"]
"requirements": ["apprise==1.11.0"]
}
+3 -2
View File
@@ -4,6 +4,7 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
"iot_class": "local_polling",
"quality_scale": "legacy"
"iot_class": "local_push",
"quality_scale": "legacy",
"requirements": ["arwn-client==0.2.1"]
}
+80 -121
View File
@@ -3,113 +3,26 @@
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,
@@ -118,28 +31,25 @@ 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 events as sensors.
"""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
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:
if device is None:
return
if (store := hass.data.get(DATA_ARWN)) is None:
@@ -148,22 +58,71 @@ async def async_setup_platform(
if "timestamp" in event:
del event["timestamp"]
for sensor in sensors:
if sensor.name not in store:
sensor.hass = hass
sensor.set_event(event)
store[sensor.name] = sensor
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
_LOGGER.debug(
"Registering sensor %(name)s => %(event)s",
{"name": sensor.name, "event": event},
{"name": reading.sensor_name, "event": event},
)
async_add_entities((sensor,), True)
new_sensors.append(sensor)
else:
_LOGGER.debug(
"Recording sensor %(name)s => %(event)s",
{"name": sensor.name, "event": event},
{"name": reading.sensor_name, "event": event},
)
store[sensor.name].set_event(event)
store[unique_id].set_event(event)
if new_sensors:
async_add_entities(new_sensors, True)
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
@@ -175,29 +134,29 @@ class ArwnSensor(SensorEntity):
def __init__(
self,
topic: str,
unique_id: 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
# This mqtt topic for the sensor which is its uid
self._attr_unique_id = topic
self._attr_unique_id = unique_id
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."""
ev: dict[str, Any] = {}
ev.update(event)
self._attr_extra_state_attributes = ev
self._attr_native_value = ev.get(self._state_key)
self._attr_extra_state_attributes = dict(event)
self._attr_native_value = event.get(self._state_key)
self.async_write_ha_state()
+19 -5
View File
@@ -2,12 +2,18 @@
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -15,12 +21,20 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
address = entry.data[CONF_ADDRESS]
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
entry.runtime_data = avea.Bulb(ble_device)
@@ -22,6 +22,11 @@
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Avea device with address {address}: {reason}"
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
+88 -48
View File
@@ -33,23 +33,14 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
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,
}
)
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,
}
)
LOG_MSG = {
@@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
self.device_config: dict[str, Any] = {}
def handle_step_exception(
self, step, exception, schema, host, port, message_id, log_fn
self, exception, schema, host, port, message_id, log_fn, step_id
):
"""Handle step exceptions."""
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
return self.async_show_form(
step_id="user",
step_id=step_id,
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:
@@ -145,12 +162,11 @@ 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=schema,
data_schema=STEP_SCHEMA,
errors={},
description_placeholders={},
)
@@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
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
)
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
# 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,
)
+13 -1
View File
@@ -2,7 +2,9 @@
"config": {
"abort": {
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"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."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,6 +13,16 @@
},
"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%]",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
}
@@ -0,0 +1,37 @@
"""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)
@@ -0,0 +1,60 @@
"""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,
)
@@ -0,0 +1,11 @@
"""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
@@ -0,0 +1,44 @@
"""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
@@ -0,0 +1,29 @@
"""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
@@ -0,0 +1,12 @@
{
"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"]
}
@@ -0,0 +1,90 @@
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
@@ -0,0 +1,185 @@
"""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
@@ -0,0 +1,76 @@
{
"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"
}
}
}
}
+2 -2
View File
@@ -1,7 +1,6 @@
"""Support for entities of the Evohome integration."""
from collections.abc import Mapping
from datetime import UTC, datetime
import logging
from typing import Any
@@ -14,6 +13,7 @@ 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 < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
and until < dt_util.utcnow()
)
): # must use self._setpoints, not self.setpoints
await get_schedule()
+3 -3
View File
@@ -1,6 +1,6 @@
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any, NotRequired, TypedDict
from evohomeasync.auth import (
@@ -12,6 +12,7 @@ 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
@@ -91,8 +92,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
if session_id_expires is None:
# pylint: disable-next=home-assistant-enforce-utcnow
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
else:
self._session_id_expires = datetime.fromisoformat(session_id_expires)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.2"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
@@ -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=10011
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
- Auth failure is signalled by raising GrowattV1ApiError with
error_code=GrowattV1ApiErrorCode.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 == V1_API_ERROR_NO_PRIVILEGE
- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE
→ raise ConfigEntryAuthFailed
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
@@ -30,6 +30,7 @@ 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
@@ -58,8 +59,6 @@ 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
@@ -239,15 +238,24 @@ def _login_classic_api(
login_response = api.login(username, password)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(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("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
)
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
return login_response
@@ -265,17 +273,25 @@ def get_device_list_v1(
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": e.error_msg or str(e)},
) from e
if e.error_code == V1_API_ERROR_RATE_LIMITED:
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
raise ConfigEntryNotReady(
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
translation_domain=DOMAIN,
translation_key="rate_limited",
translation_placeholders={"error": e.error_msg or str(e)},
) from e
raise ConfigEntryError(
f"API error during device list: {e.error_msg or str(e)}"
f" (Code: {e.error_code})"
translation_domain=DOMAIN,
translation_key="api_error_with_code",
translation_placeholders={
"error": e.error_msg or str(e),
"code": str(e.error_code),
},
) from e
devices = devices_dict.get("devices", [])
supported_devices = [
@@ -349,10 +365,15 @@ async def async_setup_entry(
devices = await hass.async_add_executor_job(api.device_list, plant_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(ex)},
) from ex
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unknown_auth_type",
)
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -5,6 +5,7 @@ import logging
from typing import Any
import growattServer
from growattServer import GrowattV1ApiErrorCode
import requests
import voluptuous as vol
@@ -32,7 +33,6 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
if err.error_code == GrowattV1ApiErrorCode.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 == V1_API_ERROR_NO_PRIVILEGE:
if e.error_code == GrowattV1ApiErrorCode.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,13 +42,6 @@ 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,6 +6,7 @@ import logging
from typing import TYPE_CHECKING, Any
import growattServer
from growattServer import GrowattV1ApiErrorCode
from requests import RequestException
from homeassistant.components.sensor import SensorStateClass
@@ -27,7 +28,6 @@ from .const import (
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .models import GrowattRuntimeData
@@ -113,9 +113,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if device.get("type") in V1_DEVICE_TYPES
]
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
self.device_list = None
@@ -157,9 +159,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
"Username, password, or URL may be incorrect"
translation_domain=DOMAIN,
translation_key="invalid_credentials",
)
raise UpdateFailed(f"Growatt login failed: {msg}")
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="login_failed",
translation_placeholders={"message": msg},
)
if self.device_type == "total":
if self.api_version == "v1":
@@ -179,13 +186,18 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={
"error": err.error_msg or str(err)
},
) from err
raise UpdateFailed(
f"Error fetching plant energy overview: {err}"
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
@@ -212,12 +224,17 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
@@ -240,12 +257,17 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
"Authentication failed for Growatt API:"
f" {err.error_msg or str(err)}"
translation_domain=DOMAIN,
translation_key="auth_failed",
translation_placeholders={"error": err.error_msg or str(err)},
) from err
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(err)},
) from err
combined = {**sph_detail, **sph_energy}
@@ -313,7 +335,11 @@ 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(f"Error fetching data: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_data_failed",
translation_placeholders={"error": str(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": "silver",
"quality_scale": "gold",
"requirements": ["growattServer==2.1.0"]
}
@@ -56,7 +56,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -595,6 +595,15 @@
"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."
},
@@ -604,6 +613,9 @@
"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}."
},
@@ -613,6 +625,9 @@
"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}."
},
@@ -634,11 +649,20 @@
"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 -1
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.FAN]
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
@@ -66,14 +66,8 @@ rules:
comment: A config entry represents a single fixed device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The only entity is the primary fan, which is enabled by default.
entity-translations:
status: exempt
comment: |
The only entity is the primary fan, which uses the device name and has
no name of its own to translate.
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
+87
View File
@@ -0,0 +1,87 @@
"""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)
@@ -18,5 +18,18 @@
"title": "Connect to your Helty Flow"
}
}
},
"entity": {
"sensor": {
"indoor_humidity": {
"name": "Indoor humidity"
},
"indoor_temperature": {
"name": "Indoor temperature"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
}
}
}
}
@@ -6,7 +6,14 @@ import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.exceptions import InvalidAuth
from pygti.exceptions import GTIError
from pygti.models import (
ElevatorState,
SDName,
SDNameType,
StationInformationRequest,
StationInformationResponse,
)
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -38,20 +45,21 @@ async def async_setup_entry(
station = entry.data[CONF_STATION]
def get_elevator_entities_from_station_information(
station_name, station_information
):
station_name: str,
station_information: StationInformationResponse | None,
) -> dict[str, Any]:
"""Convert station information into a list of elevators."""
elevators = {}
if station_information is None:
return {}
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")
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
if label is not None:
name = f"Elevator {label}"
@@ -61,7 +69,7 @@ async def async_setup_entry(
if description is not None:
name += f" ({description})"
lines = elevator.get("lines")
lines = elevator.lines
idx = f"{station_name}-{label}-{lines}"
@@ -70,33 +78,35 @@ async def async_setup_entry(
"name": name,
"available": available,
"attributes": {
"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"),
"cabin_width": elevator.cabinWidth,
"cabin_length": elevator.cabinLength,
"door_width": elevator.doorWidth,
"elevator_type": elevator.elevatorType,
"button_type": elevator.buttonType,
"cause": elevator.cause,
"lines": lines,
},
}
return elevators
async def async_update_data():
async def async_update_data() -> dict[str, Any]:
"""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 = {"station": {"id": station["id"], "type": station["type"]}}
payload = StationInformationRequest(
station=SDName(id=station["id"], type=SDNameType(station["type"]))
)
try:
async with asyncio.timeout(10):
return get_elevator_entities_from_station_information(
station_name, await hub.gti.stationInformation(payload)
station_name, await hub.gti.getStationInformation(payload)
)
except InvalidAuth as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
except GTIError as err:
raise UpdateFailed(f"GTI API error: {err}") from err
except ClientConnectorError as err:
raise UpdateFailed(f"Network not available: {err}") from err
except Exception as err:
@@ -129,7 +139,12 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, coordinator, idx, config_entry):
def __init__(
self,
coordinator: DataUpdateCoordinator[dict[str, Any]],
idx: str,
config_entry: HVVConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.coordinator = coordinator
@@ -140,7 +155,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"],
@@ -154,7 +169,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return entity state."""
return self.coordinator.data[self.idx]["state"]
return bool(self.coordinator.data[self.idx]["state"])
@property
def available(self) -> bool:
@@ -3,8 +3,17 @@
import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.auth import GTI_DEFAULT_HOST
from pygti.exceptions import CannotConnect, InvalidAuth
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import (
CNRequest,
DLRequest,
GTITime,
RegionalSDNameType,
SDName,
SDNameType,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -66,10 +75,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
try:
response = await self.hub.authenticate()
_LOGGER.debug("Init gti: %r", response)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
except GTIError, ClientConnectorError:
errors["base"] = "cannot_connect"
if not errors:
self.data = user_input
@@ -87,15 +96,14 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
check_name = await self.hub.gti.checkName(
{"theName": {"name": user_input[CONF_STATION]}, "maxList": 20}
CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20)
)
stations = check_name.get("results")
self.stations = {
f"{station.get('name')}": station
for station in stations
if station.get("type") == "STATION"
station.name: station
for station in (check_name.results or [])
if station.type == RegionalSDNameType.STATION
and station.name is not None
}
if not self.stations:
@@ -121,7 +129,13 @@ 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]]})
self.data.update(
{
"station": self.stations[user_input[CONF_STATION]].model_dump(
mode="json", exclude_none=True
)
}
)
title = self.data[CONF_STATION]["name"]
@@ -151,32 +165,30 @@ 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(
{
"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,
}
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,
)
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except GTIUnauthorizedError:
errors["base"] = "invalid_auth"
if not errors:
except GTIError, ClientConnectorError:
errors["base"] = "cannot_connect"
else:
self.departure_filters = {
str(i): departure_filter
for i, departure_filter in enumerate(departure_list["filter"])
str(i): f.model_dump(mode="json", exclude_none=True)
for i, f in enumerate(departure_list.filter or [])
}
if user_input is not None and not errors:
@@ -206,8 +218,8 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select(
{
key: (
f"{departure_filter['serviceName']},"
f" {departure_filter['label']}"
f"{departure_filter.get('serviceName', '')},"
f" {departure_filter.get('label', '')}"
)
for key, departure_filter in self.departure_filters.items()
}
@@ -1,6 +1,8 @@
"""Hub."""
from aiohttp import ClientSession
from pygti.gti import GTI, Auth
from pygti.models import InitRequest, InitResponse
from homeassistant.config_entries import ConfigEntry
@@ -10,7 +12,9 @@ type HVVConfigEntry = ConfigEntry[GTIHub]
class GTIHub:
"""GTI Hub."""
def __init__(self, host, username, password, session):
def __init__(
self, host: str, username: str, password: str, session: ClientSession
) -> None:
"""Initialize."""
self.host = host
self.username = username
@@ -18,7 +22,7 @@ class GTIHub:
self.gti = GTI(Auth(session, self.username, self.password, self.host))
async def authenticate(self):
async def authenticate(self) -> InitResponse:
"""Test if we can authenticate with the host."""
return await self.gti.init()
return await self.gti.init(InitRequest())
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pygti"],
"requirements": ["pygti==0.9.4"]
"requirements": ["pygti==1.1.1"]
}
@@ -4,8 +4,9 @@ from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectorError
from pygti.exceptions import InvalidAuth
from aiohttp import ClientConnectorError, ClientSession
from pygti.exceptions import GTIError, GTIUnauthorizedError
from pygti.models import DLRequest, GTITime, SDName, SDNameType
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import ATTR_ID, CONF_OFFSET
@@ -16,8 +17,15 @@ 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_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER
from .hub import HVVConfigEntry
from .const import (
ATTRIBUTION,
CONF_FILTER,
CONF_REAL_TIME,
CONF_STATION,
DOMAIN,
MANUFACTURER,
)
from .hub import GTIHub, HVVConfigEntry
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
MAX_LIST = 20
@@ -62,11 +70,17 @@ class HVVDepartureSensor(SensorEntity):
_attr_has_entity_name = True
_attr_available = False
def __init__(self, hass, config_entry, session, hub):
def __init__(
self,
hass: HomeAssistant,
config_entry: HVVConfigEntry,
session: ClientSession,
hub: GTIHub,
) -> None:
"""Initialize."""
self.config_entry = config_entry
self.station_name = self.config_entry.data[CONF_STATION]["name"]
self._last_error = None
self._last_error: type[Exception] | Exception | None = None
self._attr_extra_state_attributes = {}
self.gti = hub.gti
@@ -77,7 +91,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"],
@@ -99,39 +113,46 @@ class HVVDepartureSensor(SensorEntity):
station = self.config_entry.data[CONF_STATION]
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"]})
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),
)
try:
data = await self.gti.departureList(payload)
except InvalidAuth as error:
if self._last_error != InvalidAuth:
data = await self.gti.departureList(request)
except GTIUnauthorizedError as error:
if self._last_error != GTIUnauthorizedError:
_LOGGER.error("Authentication failed: %r", error)
self._last_error = InvalidAuth
self._last_error = GTIUnauthorizedError
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["returnCode"] == "OK" and data.get("departures")):
if not data.departures:
self._attr_available = False
return
@@ -140,25 +161,27 @@ class HVVDepartureSensor(SensorEntity):
self._last_error = None
departure = data["departures"][0]
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
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
self._attr_available = True
self._attr_native_value = (
departure_time
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(
minutes=departure.timeOffset if departure.timeOffset is not None else 0
)
+ 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,
@@ -166,21 +189,27 @@ class HVVDepartureSensor(SensorEntity):
)
departures = []
for departure in data["departures"]:
line = departure["line"]
delay = departure.get("delay", 0)
cancelled = departure.get("cancelled", False)
extra = departure.get("extra", False)
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
departures.append(
{
ATTR_DEPARTURE: departure_time
+ timedelta(minutes=departure["timeOffset"])
+ timedelta(
minutes=departure.timeOffset
if departure.timeOffset is not None
else 0
)
+ 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
@@ -0,0 +1,42 @@
"""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
@@ -0,0 +1,109 @@
"""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
@@ -0,0 +1,80 @@
"""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
@@ -0,0 +1,39 @@
"""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]
@@ -0,0 +1,152 @@
"""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
@@ -0,0 +1,59 @@
"""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
@@ -0,0 +1,18 @@
{
"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"
}
}
}
}
@@ -0,0 +1,11 @@
{
"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"]
}
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,56 @@
{
"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.6.1"]
"requirements": ["infrared-protocols==5.8.1"]
}
+1 -1
View File
@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"knx-frontend==2026.4.30.60856"
"knx-frontend==2026.6.1.213802"
],
"single_config_entry": true
}
@@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PLATFORMS = [Platform.NUMBER, Platform.SENSOR]
@@ -25,9 +26,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) ->
try:
await charger.test_and_get()
except TimeoutError as ex:
raise ConfigEntryNotReady("Unable to connect to charger") from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="communication_error",
) from ex
except AuthenticationError as ex:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from ex
coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
await coordinator.async_config_entry_first_refresh()
@@ -63,7 +63,11 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
await self.charger.update()
except TimeoutError as error:
raise UpdateFailed(
f"Timeout communicating with charger: {error}"
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
except AuthenticationError as error:
raise ConfigEntryAuthFailed("Invalid credentials for charger") from error
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from error
@@ -168,10 +168,10 @@
},
"exceptions": {
"authentication_error": {
"message": "Authentication failed while communicating with the charger."
"message": "Authentication failed"
},
"communication_error": {
"message": "Failed to communicate with the charger."
"message": "Failed to communicate with the charger"
},
"invalid_value": {
"message": "Value {value} is invalid for the charger."
@@ -2,7 +2,7 @@
import asyncio
import logging
from typing import Any, cast
from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from httpx import HTTPError, InvalidURL
@@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:
# Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports
# the 2.0.0 API, but doesn't advertise it yet
original = cast(str, version.get("original", ""))
original_value = version.get("original")
original = original_value if isinstance(original_value, str) else ""
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
):
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.3"]
"requirements": ["pysmartthings==4.0.0"]
}
@@ -3,7 +3,9 @@
import datetime
from functools import partial
import logging
import os
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from soco import SoCo, alarms
from soco.core import (
@@ -90,6 +92,7 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"})
async def async_setup_entry(
@@ -460,6 +463,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
ext = os.path.splitext(urlparse(media_id).path)[1].lower()
if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS:
_LOGGER.warning(
"Sonos AudioClip announce only supports MP3 and WAV; "
"%s has extension %s and will be attempted as a clip anyway on %s",
media_id,
ext,
self.speaker.zone_name,
)
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
@@ -61,7 +61,11 @@ PLATFORMS_BY_TYPE = {
Platform.SENSOR,
Platform.SELECT,
],
SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.CONTACT.value: [
Platform.BINARY_SENSOR,
Platform.EVENT,
Platform.SENSOR,
],
SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.HUMIDIFIER.value: [Platform.HUMIDIFIER, Platform.SENSOR],
+2 -2
View File
@@ -218,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or (
_tilt > self.CLOSED_UP_THRESHOLD
)
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
+36 -16
View File
@@ -1,5 +1,7 @@
"""Support for SwitchBot event entities."""
from dataclasses import dataclass
from homeassistant.components.event import (
EventDeviceClass,
EventEntity,
@@ -13,13 +15,31 @@ from .entity import SwitchbotEntity
PARALLEL_UPDATES = 0
EVENT_TYPES = {
"doorbell": EventEntityDescription(
@dataclass(frozen=True, kw_only=True)
class SwitchbotEventEntityDescription(EventEntityDescription):
"""Describes a Switchbot event entity."""
counter_key: str
fire_event: str
EVENT_DESCRIPTIONS: tuple[SwitchbotEventEntityDescription, ...] = (
SwitchbotEventEntityDescription(
key="doorbell",
device_class=EventDeviceClass.DOORBELL,
event_types=["ring"],
counter_key="doorbell_seq",
fire_event="ring",
),
}
SwitchbotEventEntityDescription(
key="button",
device_class=EventDeviceClass.BUTTON,
event_types=["press"],
counter_key="button_count",
fire_event="press",
),
)
async def async_setup_entry(
@@ -30,34 +50,34 @@ async def async_setup_entry(
"""Set up the SwitchBot event platform."""
coordinator = config_entry.runtime_data
async_add_entities(
SwitchbotEventEntity(coordinator, event, description)
for event, description in EVENT_TYPES.items()
if event in coordinator.device.parsed_data
SwitchbotEventEntity(coordinator, description)
for description in EVENT_DESCRIPTIONS
if description.counter_key in coordinator.device.parsed_data
)
class SwitchbotEventEntity(SwitchbotEntity, EventEntity):
"""Representation of a SwitchBot event."""
entity_description: SwitchbotEventEntityDescription
def __init__(
self,
coordinator: SwitchbotDataUpdateCoordinator,
event: str,
description: EventEntityDescription,
description: SwitchbotEventEntityDescription,
) -> None:
"""Initialize the SwitchBot event."""
super().__init__(coordinator)
self._event = event
self.entity_description = description
self._attr_unique_id = f"{coordinator.base_unique_id}-{event}"
self._previous_doorbell_seq = int(
coordinator.device.parsed_data.get("doorbell_seq", 0)
self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}"
self._previous_counter = int(
coordinator.device.parsed_data.get(description.counter_key, 0)
)
@callback
def _async_update_attrs(self) -> None:
"""Update the entity attributes."""
seq = int(self.parsed_data.get("doorbell_seq", 0))
if seq not in (0, self._previous_doorbell_seq):
self._trigger_event("ring")
self._previous_doorbell_seq = seq
counter = int(self.parsed_data.get(self.entity_description.counter_key, 0))
if counter not in (0, self._previous_counter):
self._trigger_event(self.entity_description.fire_event)
self._previous_counter = counter
@@ -110,17 +110,15 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = {
True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
),
"Home Climate Panel": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR)
),
"WeatherStation": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.SENSOR,)
),
"Meter": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPlus": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"WoIOSensor": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"Hub 2": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPro": SwitchbotCloudDeviceConfig(False, entity_config=(Platform.SENSOR,)),
"MeterPro(CO2)": SwitchbotCloudDeviceConfig(
False, entity_config=(Platform.SENSOR,)
True, entity_config=(Platform.SENSOR,)
),
"Meter": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPlus": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
"MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)),
}
@@ -139,12 +139,14 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "Zones",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.",
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
"name": "[%key:common::config_flow::data::name%]"
@@ -715,11 +717,13 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
+27 -5
View File
@@ -3,7 +3,11 @@
from abc import abstractmethod
from typing import Any
from victron_mqtt import Device as VictronVenusDevice, Metric as VictronVenusMetric
from victron_mqtt import (
Device as VictronVenusDevice,
Metric as VictronVenusMetric,
MetricType,
)
from homeassistant.const import EntityCategory
from homeassistant.core import callback
@@ -14,6 +18,8 @@ from homeassistant.helpers.entity import Entity
ENTITIES_CATEGORY_DIAGNOSTIC = ["system_heartbeat", "platform_device_reboot"]
# Entities that should be disabled by default
ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat", "platform_device_reboot"]
# Units that must be provided directly instead of via localization.
SPECIAL_NATIVE_UNITS = {"%", "Ah"}
class VictronBaseEntity(Entity):
@@ -46,10 +52,6 @@ class VictronBaseEntity(Entity):
if metric.main_topic:
self._attr_name = None
# Special case for "%" as it should not be coming from the localization file
self._attr_native_unit_of_measurement = (
"%" if metric.unit_of_measurement == "%" else None
)
self._attr_entity_category = (
EntityCategory.DIAGNOSTIC
if metric.generic_short_id in ENTITIES_CATEGORY_DIAGNOSTIC
@@ -59,6 +61,26 @@ class VictronBaseEntity(Entity):
metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT
)
def _native_unit_of_measurement(self) -> str | None:
unit_of_measurement = self._metric.unit_of_measurement
# We need to provide a native unit in three cases:
if (
# 1. Special units which will never need a translation and therefore will not be included in the translation file.
unit_of_measurement in SPECIAL_NATIVE_UNITS
# 2. When there is known device class which support multiple units. In this case
# we publish what we have and HA will allow conversion to other supported units.
# We specifically don't put those cases in the translation file by the merge script
# not to waste translation resources so it has to come from here.
or self._attr_device_class is not None
# 3. Dynamic units come from user-configured MQTT topics (e.g.
# SwitchableOutput Settings/Unit) and have no translation file
# entry, so we must set the unit programmatically.
or self._metric.metric_type == MetricType.DYNAMIC
):
return unit_of_measurement
return None
@callback
@abstractmethod
def _on_update_cb(self, value: Any) -> None:
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["victron-mqtt==2026.5.4"],
"requirements": ["victron-mqtt==2026.6.1"],
"ssdp": [
{
"X_MqttOnLan": "1",
@@ -32,6 +32,7 @@ METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, NumberDeviceClass] = {
MetricType.SPEED: NumberDeviceClass.SPEED,
MetricType.LIQUID_VOLUME: NumberDeviceClass.VOLUME_STORAGE,
MetricType.DURATION: NumberDeviceClass.DURATION,
MetricType.IRRADIANCE: NumberDeviceClass.IRRADIANCE,
}
@@ -71,8 +72,7 @@ class VictronNumber(VictronBaseEntity, NumberEntity):
"""Initialize the number entity."""
super().__init__(device, metric, device_info, installation_id)
self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type)
if self._attr_device_class is not None:
self._attr_native_unit_of_measurement = metric.unit_of_measurement
self._attr_native_unit_of_measurement = self._native_unit_of_measurement()
self._attr_native_value = metric.value
if metric.min_value is not None:
self._attr_native_min_value = metric.min_value
@@ -38,6 +38,7 @@ METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, SensorDeviceClass] = {
MetricType.LIQUID_VOLUME: SensorDeviceClass.VOLUME_STORAGE,
MetricType.DURATION: SensorDeviceClass.DURATION,
MetricType.ENUM: SensorDeviceClass.ENUM,
MetricType.IRRADIANCE: SensorDeviceClass.IRRADIANCE,
}
METRIC_NATURE_TO_STATE_CLASS: dict[MetricNature, SensorStateClass] = {
@@ -96,11 +97,7 @@ class VictronSensor(VictronBaseEntity, SensorEntity):
self._attr_state_class = METRIC_NATURE_TO_STATE_CLASS.get(
metric.metric_nature
)
# Only set native_unit_of_measurement when a device_class is present.
# Entities without a device_class get their display unit from
# the translation files instead.
if self._attr_device_class is not None:
self._attr_native_unit_of_measurement = metric.unit_of_measurement
self._attr_native_unit_of_measurement = self._native_unit_of_measurement()
self._attr_native_value = VictronSensor._normalize_value(metric.value)
@callback
@@ -47,6 +47,7 @@
"inverter_only": "Inverter only",
"inverting": "Inverting",
"lost_communication_with_device": "Lost communication with device",
"low_battery_alarm": "Low battery alarm",
"low_power": "Low power",
"max_power_today": "Max power today",
"max_power_yesterday": "Max power yesterday",
@@ -70,6 +71,7 @@
"power_phase": "Power {phase}",
"power_supply": "Power supply",
"pv_bus_voltage": "PV bus voltage",
"pv_current": "PV current",
"pv_power_total": "PV power total",
"recharging": "Recharging",
"repeated_absorption": "Repeated absorption",
@@ -228,6 +230,9 @@
},
"vebus_inverter_connected": {
"name": "[%key:common::state::connected%]"
},
"vebus_inverter_remote_generator_selected": {
"name": "Remote generator selected"
}
},
"button": {
@@ -565,8 +570,7 @@
"name": "Average discharge"
},
"battery_capacity": {
"name": "Capacity",
"unit_of_measurement": "Ah"
"name": "Capacity"
},
"battery_cell_cell_id_voltage": {
"name": "Cell {cell_id} voltage"
@@ -586,12 +590,10 @@
"name": "Charged energy"
},
"battery_consumed_amphours": {
"name": "Consumed amp-hours",
"unit_of_measurement": "Ah"
"name": "Consumed amp-hours"
},
"battery_cumulative_ah_drawn": {
"name": "Cumulative Ah drawn",
"unit_of_measurement": "Ah"
"name": "Cumulative Ah drawn"
},
"battery_current": {
"name": "DC bus current"
@@ -627,8 +629,7 @@
}
},
"battery_installed_capacity": {
"name": "Installed capacity",
"unit_of_measurement": "Ah"
"name": "Installed capacity"
},
"battery_internal_failure": {
"name": "Internal failure",
@@ -989,6 +990,9 @@
"evcharger_total_energy": {
"name": "Total energy"
},
"generator_next_test_run": {
"name": "Next test run"
},
"generator_run_state": {
"name": "Run state",
"state": {
@@ -1144,6 +1148,32 @@
"inverter_total_pv_yield_user": {
"name": "[%key:component::victron_gx::common::total_pv_yield_user%]"
},
"meteo_alarm_low_battery": {
"name": "[%key:component::victron_gx::common::low_battery_alarm%]",
"state": {
"alarm": "[%key:component::victron_gx::common::alarm%]",
"no_alarm": "[%key:component::victron_gx::common::no_alarm%]",
"warning": "[%key:component::victron_gx::common::warning%]"
}
},
"meteo_battery_voltage": {
"name": "Battery voltage"
},
"meteo_cell_temperature": {
"name": "Cell temperature"
},
"meteo_installation_power": {
"name": "Installation power"
},
"meteo_irradiance": {
"name": "Irradiance"
},
"meteo_time_since_last_sun": {
"name": "Time since last sun"
},
"meteo_todays_yield": {
"name": "Today's yield"
},
"multi_acin1_to_acout": {
"name": "AC-in-1 to AC-out"
},
@@ -1159,6 +1189,9 @@
"multi_acin_voltage_phase": {
"name": "[%key:component::victron_gx::common::voltage_on_phase%]"
},
"multi_acout_current_phase": {
"name": "Output current on {phase}"
},
"multi_acout_output_current_phase": {
"name": "AC-out-{output} current on {phase}"
},
@@ -1168,12 +1201,18 @@
"multi_acout_output_voltage_phase": {
"name": "AC-out-{output} voltage on {phase}"
},
"multi_acout_power_phase": {
"name": "Output power on {phase}"
},
"multi_acout_to_acin1": {
"name": "AC-out to AC-in-1"
},
"multi_acout_to_inverter": {
"name": "AC-out to inverter"
},
"multi_acout_voltage_phase": {
"name": "Output voltage on {phase}"
},
"multi_active_input": {
"name": "[%key:component::victron_gx::common::active_ac_input%]",
"state": {
@@ -1215,6 +1254,9 @@
"multi_mppt_mppt_id_yield_yesterday": {
"name": "MPPT {mppt_id} yield yesterday"
},
"multi_mppt_mpptnumber_current": {
"name": "MPPT {mpptnumber} current"
},
"multi_mppt_mpptnumber_power": {
"name": "MPPT {mpptnumber} power"
},
@@ -1382,6 +1424,9 @@
"voltage_current_limited": "[%key:component::victron_gx::common::voltage_current_limited%]"
}
},
"solarcharger_pv_current": {
"name": "[%key:component::victron_gx::common::pv_current%]"
},
"solarcharger_state": {
"name": "[%key:component::victron_gx::common::state%]",
"state": {
@@ -1418,6 +1463,9 @@
"solarcharger_time_in_float_today": {
"name": "Time in float today"
},
"solarcharger_tracker_tracker_current": {
"name": "PV tracker {tracker} current"
},
"solarcharger_tracker_tracker_max_power_today": {
"name": "Tracker {tracker} max power today"
},
@@ -1532,7 +1580,7 @@
"name": "DC consumption"
},
"system_dc_pv_current": {
"name": "PV current"
"name": "[%key:component::victron_gx::common::pv_current%]"
},
"system_dc_pv_energy": {
"name": "PV energy"
@@ -1844,7 +1892,7 @@
}
},
"vebus_inverter_alarm_low_battery": {
"name": "Low battery alarm",
"name": "[%key:component::victron_gx::common::low_battery_alarm%]",
"state": {
"alarm": "[%key:component::victron_gx::common::alarm%]",
"no_alarm": "[%key:component::victron_gx::common::no_alarm%]",
@@ -1999,6 +2047,9 @@
"generator_manual_start": {
"name": "Manual start"
},
"hub4_force_charge": {
"name": "Force charge"
},
"multi_disable_charge": {
"name": "ESS disable charge"
},
@@ -2027,10 +2078,10 @@
"name": "Relay {relay} state"
},
"system_settings_overvoltage_feedin": {
"name": "ESS feed-in excess solar charger power"
"name": "DC-coupled PV - feed in excess"
},
"system_settings_prevent_ac_feedin": {
"name": "ESS PV inverter zero feed-in"
"name": "AC-coupled PV - feed in excess"
},
"vebus_device_device_number_power_assist_enabled": {
"name": "{device_number} PowerAssist enabled"
@@ -2038,6 +2089,9 @@
"vebus_inverter_ignoreacin1_onoff_control": {
"name": "Control ignore AC-in-1"
},
"vebus_inverter_prefer_renewable_energy": {
"name": "Prefer renewable energy"
},
"vebus_inverter_setting_alarm_grid_lost": {
"name": "Grid lost alarm setting"
}
@@ -16,7 +16,7 @@ from .coordinator import VistapoolDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR]
@dataclass
@@ -0,0 +1,73 @@
"""Vistapool Button entities."""
import asyncio
from aioaquarite import AquariteError
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
from .const import DOMAIN
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
PARALLEL_UPDATES = 1
_HASLED_PATH = "main.hasLED"
_LIGHT_STATUS_PATH = "light.status"
_LED_PULSE_DELAY_SECONDS = 1.0
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vistapool buttons for every pool that has an LED fixture."""
async_add_entities(
VistapoolLEDPulseButton(coordinator)
for coordinator in entry.runtime_data.coordinators.values()
if coordinator.get_value(_HASLED_PATH)
)
class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
"""Power-cycle the pool light to advance the LED fixture's color.
Mirrors the "Next" button under LED Color in the Vistapool app's
Illumination screen. If the light is on, sends light.status=0, waits a
moment, then light.status=1; the physical LED fixture advances to the
next color on power-on. If the light is off, just turns it on.
"""
_attr_translation_key = "led_pulse"
def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
"""Initialize the LED pulse button."""
super().__init__(coordinator)
self._attr_unique_id = self.build_unique_id("led_pulse")
async def async_press(self) -> None:
"""Send a color-advance pulse to the pool LED fixture."""
try:
if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"):
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0
)
await asyncio.sleep(_LED_PULSE_DELAY_SECONDS)
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1
)
except AquariteError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
# Optimistically reflect the just-written value so a rapid second press
# doesn't read the stale off-state before the Firestore push round-trips.
self.coordinator.data.setdefault("light", {})["status"] = 1
self.coordinator.async_set_updated_data(self.coordinator.data)
@@ -3,6 +3,11 @@
"name": "Vistapool",
"codeowners": ["@fdebrus"],
"config_flow": true,
"dhcp": [
{
"hostname": "sugarwifi"
}
],
"documentation": "https://www.home-assistant.io/integrations/vistapool",
"integration_type": "hub",
"iot_class": "cloud_push",
@@ -0,0 +1,235 @@
"""Vistapool Number entities."""
from collections.abc import Callable
from dataclasses import dataclass
from aioaquarite import AquariteError
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import (
EntityCategory,
UnitOfElectricPotential,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VistapoolConfigEntry
from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity
PARALLEL_UPDATES = 1
_TEMP_MIN = 5.0
_TEMP_MAX = 40.0
@dataclass(frozen=True, kw_only=True)
class VistapoolNumberEntityDescription(NumberEntityDescription):
"""Describes a Vistapool number entity."""
value_path: str
scale: int = 1
exists_path: str | tuple[str, ...] | None = None
max_value_fn: Callable[[VistapoolDataUpdateCoordinator], float] | None = None
def _max_electrolysis(coordinator: VistapoolDataUpdateCoordinator) -> float:
"""Read the cell's hardware max, falling back to a safe default."""
raw = coordinator.get_value("hidro.maxAllowedValue")
if raw is None:
return 50.0
try:
return float(raw) / 10
except TypeError, ValueError:
return 50.0
NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = (
VistapoolNumberEntityDescription(
key="redox_setpoint",
translation_key="redox_setpoint",
entity_category=EntityCategory.CONFIG,
native_min_value=500,
native_max_value=800,
native_step=1,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
value_path="modules.rx.status.value",
exists_path=PATH_HASRX,
),
VistapoolNumberEntityDescription(
key="ph_minimum",
translation_key="ph_minimum",
device_class=NumberDeviceClass.PH,
entity_category=EntityCategory.CONFIG,
native_min_value=6,
native_max_value=8,
native_step=0.01,
value_path="modules.ph.status.low_value",
scale=100,
exists_path=PATH_HASPH,
),
VistapoolNumberEntityDescription(
key="ph_maximum",
translation_key="ph_maximum",
device_class=NumberDeviceClass.PH,
entity_category=EntityCategory.CONFIG,
native_min_value=6,
native_max_value=8,
native_step=0.01,
value_path="modules.ph.status.high_value",
scale=100,
exists_path=PATH_HASPH,
),
VistapoolNumberEntityDescription(
key="intel_temperature",
translation_key="intel_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
native_min_value=_TEMP_MIN,
native_max_value=_TEMP_MAX,
native_step=1,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_path="filtration.intel.temp",
),
*(
VistapoolNumberEntityDescription(
key=key,
translation_key=key,
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
native_min_value=_TEMP_MIN,
native_max_value=_TEMP_MAX,
native_step=1,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_path=value_path,
exists_path=exists_path,
)
for key, value_path, exists_path in (
(
"heating_minimum_temperature",
"filtration.heating.temp",
"filtration.hasHeat",
),
(
"heating_maximum_temperature",
"filtration.heating.tempHi",
"filtration.hasHeat",
),
(
"smart_minimum_temperature",
"filtration.smart.tempMin",
"filtration.hasSmart",
),
(
"smart_maximum_temperature",
"filtration.smart.tempHigh",
"filtration.hasSmart",
),
)
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vistapool number entities for every pool on the account."""
entities: list[NumberEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
for description in NUMBER_DESCRIPTIONS:
if description.exists_path is not None:
required = (
(description.exists_path,)
if isinstance(description.exists_path, str)
else description.exists_path
)
if not all(coordinator.get_value(path) for path in required):
continue
entities.append(VistapoolNumber(coordinator, description))
if coordinator.get_value(PATH_HASHIDRO):
key = (
"hydrolysis_setpoint"
if coordinator.get_value("hidro.is_electrolysis") is False
else "electrolysis_setpoint"
)
entities.append(
VistapoolNumber(
coordinator,
VistapoolNumberEntityDescription(
key=key,
translation_key=key,
entity_category=EntityCategory.CONFIG,
native_min_value=0,
native_max_value=50.0,
native_step=0.1,
native_unit_of_measurement="g/h",
value_path="hidro.level",
scale=10,
max_value_fn=_max_electrolysis,
),
)
)
async_add_entities(entities)
class VistapoolNumber(VistapoolEntity, NumberEntity):
"""Generic Vistapool number driven by an entity description."""
entity_description: VistapoolNumberEntityDescription
def __init__(
self,
coordinator: VistapoolDataUpdateCoordinator,
description: VistapoolNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = self.build_unique_id(description.key)
@property
def native_max_value(self) -> float:
"""Return the max value, recomputed from coordinator data when applicable."""
if (fn := self.entity_description.max_value_fn) is not None:
return fn(self.coordinator)
return super().native_max_value
@property
def native_value(self) -> float | None:
"""Return the scaled current value."""
raw = self.coordinator.get_value(self.entity_description.value_path)
if raw is None:
return None
try:
value = float(raw)
except TypeError, ValueError:
return None
return value / self.entity_description.scale
async def async_set_native_value(self, value: float) -> None:
"""Send the de-scaled value to the controller."""
raw = round(value * self.entity_description.scale)
try:
await self.coordinator.api.set_value(
self.coordinator.pool_id,
self.entity_description.value_path,
raw,
)
except AquariteError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
@@ -2,7 +2,7 @@ rules:
# Bronze
action-setup:
status: exempt
comment: No service actions in initial sensor-only platform
comment: No integration-specific service actions; entities use platform-standard actions only
appropriate-polling: done
brands: done
common-modules: done
@@ -11,7 +11,7 @@ rules:
dependency-transparency: done
docs-actions:
status: exempt
comment: No service actions in initial sensor-only platform
comment: No integration-specific service actions to document
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
@@ -24,14 +24,12 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No user actions (sensor-only platform)
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-troubleshooting: todo
docs-troubleshooting: done
entity-category: done
entity-disabled-by-default: done
integration-owner: done
@@ -43,14 +41,16 @@ rules:
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-use-cases: todo
discovery: done
discovery-update-info:
status: exempt
comment: Integration is cloud-only; no local host info is stored on the config entry.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-use-cases: done
dynamic-devices: todo
entity-translations: done
exception-translations: done
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,7 @@
"no_pools": "No pools were found on this account.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Vistapool pool controller",
"step": {
"user": {
"data": {
@@ -25,6 +27,43 @@
}
},
"entity": {
"button": {
"led_pulse": {
"name": "LED next color"
}
},
"number": {
"electrolysis_setpoint": {
"name": "Electrolysis setpoint"
},
"heating_maximum_temperature": {
"name": "Heating maximum temperature"
},
"heating_minimum_temperature": {
"name": "Heating minimum temperature"
},
"hydrolysis_setpoint": {
"name": "Hydrolysis setpoint"
},
"intel_temperature": {
"name": "Intel temperature"
},
"ph_maximum": {
"name": "pH maximum"
},
"ph_minimum": {
"name": "pH minimum"
},
"redox_setpoint": {
"name": "Redox setpoint"
},
"smart_maximum_temperature": {
"name": "Smart maximum temperature"
},
"smart_minimum_temperature": {
"name": "Smart minimum temperature"
}
},
"sensor": {
"chlorine": {
"name": "Chlorine"
@@ -59,6 +98,9 @@
"no_pools": {
"message": "No pools were found on this account."
},
"set_failed": {
"message": "Failed to set {entity}."
},
"update_failed": {
"message": "Error fetching data from Vistapool."
}
@@ -15,6 +15,7 @@ from .const import (
from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity
PLATFORMS = [
Platform.BUTTON,
Platform.LOCK,
Platform.NUMBER,
Platform.SELECT,
@@ -0,0 +1,71 @@
"""Home Assistant component for accessing the Wallbox Portal API button."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CHARGER_DATA_KEY,
CHARGER_RESUME_SCHEDULE_KEY,
CHARGER_SERIAL_NUMBER_KEY,
)
from .coordinator import WallboxConfigEntry, WallboxCoordinator
from .entity import WallboxEntity
@dataclass(frozen=True, kw_only=True)
class WallboxButtonEntityDescription(ButtonEntityDescription):
"""Describes Wallbox button entity."""
press_fn: Callable[[WallboxCoordinator], Awaitable[None]]
BUTTON_TYPES: dict[str, WallboxButtonEntityDescription] = {
CHARGER_RESUME_SCHEDULE_KEY: WallboxButtonEntityDescription(
key=CHARGER_RESUME_SCHEDULE_KEY,
translation_key=CHARGER_RESUME_SCHEDULE_KEY,
press_fn=lambda coordinator: coordinator.async_resume_schedule(),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: WallboxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox button entities in HASS."""
coordinator: WallboxCoordinator = entry.runtime_data
async_add_entities(
[WallboxButton(coordinator, BUTTON_TYPES[CHARGER_RESUME_SCHEDULE_KEY])]
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class WallboxButton(WallboxEntity, ButtonEntity):
"""Representation of the Wallbox portal."""
entity_description: WallboxButtonEntityDescription
def __init__(
self,
coordinator: WallboxCoordinator,
description: WallboxButtonEntityDescription,
) -> None:
"""Initialize a Wallbox button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{description.key}-"
f"{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}"
)
async def async_press(self) -> None:
"""Resume schedule and EcoSmart mode after a manual stop."""
await self.entity_description.press_fn(self.coordinator)
@@ -38,6 +38,7 @@ CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent"
CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current"
CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent"
CHARGER_PAUSE_RESUME_KEY = "paused"
CHARGER_RESUME_SCHEDULE_KEY = "resume_schedule"
CHARGER_LOCKED_UNLOCKED_KEY = "locked"
CHARGER_NAME_KEY = "name"
CHARGER_STATE_OF_CHARGE_KEY = "state_of_charge"
@@ -390,6 +390,31 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
await self.hass.async_add_executor_job(self._pause_charger, pause)
await self.async_request_refresh()
def _resume_schedule(self) -> None:
"""Resume schedule and EcoSmart mode after a manual stop."""
try:
self._wallbox.resumeSchedule(self._station)
except requests.exceptions.HTTPError as wallbox_connection_error:
if wallbox_connection_error.response.status_code == 403:
raise InsufficientRights(
translation_domain=DOMAIN,
translation_key="insufficient_rights",
hass=self.hass,
) from wallbox_connection_error
if wallbox_connection_error.response.status_code == 429:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="too_many_requests"
) from wallbox_connection_error
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_failed"
) from wallbox_connection_error
@_require_authentication
async def async_resume_schedule(self) -> None:
"""Resume schedule and EcoSmart mode after a manual stop."""
await self.hass.async_add_executor_job(self._resume_schedule)
await self.async_request_refresh()
def _set_eco_smart(self, option: str) -> None:
"""Set wallbox solar charging mode."""
try:
@@ -32,6 +32,11 @@
}
},
"entity": {
"button": {
"resume_schedule": {
"name": "Resume schedule"
}
},
"lock": {
"lock": {
"name": "[%key:component::lock::title%]"
@@ -39,6 +39,7 @@ from homeassistant.helpers import (
entity,
target as target_helpers,
template,
trace,
)
from homeassistant.helpers.condition import (
async_from_config as async_condition_from_config,
@@ -1026,14 +1027,53 @@ async def handle_test_condition(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle test condition command."""
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
# Test the condition
condition = await async_condition_from_config(hass, config)
# Validating and instantiating the condition can fail on bad user input.
# Handle those errors here so they are reported to the client without being
# logged as unexpected errors by the default websocket error handler.
try:
connection.send_result(
msg["id"], {"result": condition.async_check(variables=msg.get("variables"))}
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
condition = await async_condition_from_config(hass, config)
except vol.Invalid as err:
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
return
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
return
# Template errors (e.g. undefined variables) are recorded in the trace
# instead of being logged. Capture the trace and forward them to the client
# alongside the result.
condition_trace = trace.trace_get()
try:
with trace.record_template_errors():
check_result = condition.async_check(variables=msg.get("variables"))
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
else:
result: dict[str, Any] = {"result": check_result}
if template_errors := [
template_error
for elements in condition_trace.values()
for element in elements
for template_error in element.template_errors
]:
result["template_errors"] = template_errors
connection.send_result(msg["id"], result)
finally:
condition.async_unload()
@@ -1050,9 +1090,23 @@ async def handle_subscribe_condition(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe condition command."""
condition_config = await async_validate_condition_config(hass, msg["condition"])
try:
condition_config = await async_validate_condition_config(hass, msg["condition"])
condition = await async_condition_from_config(hass, condition_config)
except vol.Invalid as err:
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
return
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
return
condition = await async_condition_from_config(hass, condition_config)
event_data: dict[str, Any] = {}
@callback
@@ -1061,10 +1115,24 @@ async def handle_subscribe_condition(
nonlocal event_data
new_event_data: dict[str, Any]
condition_trace = trace.trace_get()
try:
new_event_data = {"result": condition.async_check()}
with trace.record_template_errors():
new_event_data = {"result": condition.async_check()}
except HomeAssistantError as err:
new_event_data = {"error": str(err)}
# Template errors (e.g. undefined variables) are recorded in the trace
# instead of being logged. Forward them to the client so they are not
# lost, even when the condition still evaluated to a result.
if template_errors := [
template_error
for elements in condition_trace.values()
for element in elements
for template_error in element.template_errors
]:
new_event_data["template_errors"] = template_errors
if new_event_data == event_data:
return
event_data = new_event_data
@@ -92,7 +92,9 @@ class WebSocketHandler:
self._hass = hass
self._loop = hass.loop
self._request: web.Request = request
self._wsock = web.WebSocketResponse(heartbeat=55)
# decode_text=False so orjson decodes the raw TEXT bytes directly
# instead of decoding to str first and re-scanning.
self._wsock = web.WebSocketResponse(heartbeat=55, decode_text=False)
self._handle_task: asyncio.Task | None = None
self._writer_task: asyncio.Task | None = None
self._closing: bool = False
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/wmspro",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["pywmspro==0.3.5"]
"requirements": ["pywmspro==0.4.0"]
}
+74 -32
View File
@@ -23,16 +23,43 @@ async def async_setup_entry(
) -> None:
"""Set up light platform."""
coordinator = entry.runtime_data
entities = [
XthingsCloudLight(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] == "light"
]
entities: list[LightEntity] = []
for device_id, device_data in coordinator.data.items():
dev_type = device_data.get("type")
if dev_type == "light":
entities.append(XthingsCloudLight(coordinator, device_id, device_data))
elif dev_type == "switch":
entities.append(XthingsCloudSwitch(coordinator, device_id, device_data))
async_add_entities(entities)
class XthingsCloudLight(XthingsCloudEntity, LightEntity):
"""Xthings Cloud light entity."""
class XthingsCloudBaseLight(XthingsCloudEntity, LightEntity):
"""Xthings Cloud base light entity."""
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self.device_data["status"]["on"]
@property
def brightness(self) -> int | None:
"""Return brightness (0-255)."""
level = self.device_data["status"].get("brightness")
if level is not None:
return round(level * 255 / 100)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
raise NotImplementedError
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light."""
raise NotImplementedError
class XthingsCloudLight(XthingsCloudBaseLight):
"""Xthings Cloud native light entity."""
_attr_min_color_temp_kelvin = 2000
_attr_max_color_temp_kelvin = 6500
@@ -76,19 +103,6 @@ class XthingsCloudLight(XthingsCloudEntity, LightEntity):
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self.device_data["status"]["on"]
@property
def brightness(self) -> int | None:
"""Return brightness (0-255)."""
level = self.device_data["status"].get("brightness")
if level is not None:
return round(level * 255 / 100)
return None
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the HS color value."""
@@ -107,16 +121,7 @@ class XthingsCloudLight(XthingsCloudEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
client = self.coordinator.client
has_color = ATTR_HS_COLOR in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
has_brightness = ATTR_BRIGHTNESS in kwargs
# Only send on command when no color/brightness adjustment
if not has_color and not has_brightness:
await client.async_brite_on(self._device_id)
# Adjust brightness (standalone, no color change)
if has_brightness and not has_color:
brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
await client.async_brite_brightness(self._device_id, brightness)
# Adjust HS color
if ATTR_HS_COLOR in kwargs:
hue, saturation = kwargs[ATTR_HS_COLOR]
status = self.device_data["status"]
@@ -135,8 +140,7 @@ class XthingsCloudLight(XthingsCloudEntity, LightEntity):
"brightness": cur_brightness,
},
)
# Adjust color temperature
if ATTR_COLOR_TEMP_KELVIN in kwargs:
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
status = self.device_data["status"]
cur_brightness = status.get("brightness", 100)
if ATTR_BRIGHTNESS in kwargs:
@@ -149,7 +153,45 @@ class XthingsCloudLight(XthingsCloudEntity, LightEntity):
"brightness": cur_brightness,
},
)
elif ATTR_BRIGHTNESS in kwargs:
brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
await client.async_brite_brightness(self._device_id, brightness)
else:
await client.async_brite_on(self._device_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light."""
await self.coordinator.client.async_brite_off(self._device_id)
class XthingsCloudSwitch(XthingsCloudBaseLight):
"""Xthings Cloud switch device exposed as a light entity."""
def __init__(
self,
coordinator: XthingsCloudCoordinator,
device_id: str,
device_data: dict[str, Any],
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator, device_id, device_data)
status = device_data["status"]
if "brightness" in status:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
else:
self._attr_supported_color_modes = {ColorMode.ONOFF}
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch."""
client = self.coordinator.client
if ATTR_BRIGHTNESS in kwargs:
modes = self._attr_supported_color_modes or set()
if ColorMode.BRIGHTNESS in modes:
brightness = round(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
await client.async_switch_brightness(self._device_id, brightness)
return
await client.async_switch_on(self._device_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch."""
await self.coordinator.client.async_switch_off(self._device_id)
@@ -20,7 +20,7 @@ async def async_setup_entry(
entities = [
XthingsCloudSwitch(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] in ("switch", "plug")
if device_data["type"] == "plug"
]
async_add_entities(entities)
@@ -35,14 +35,8 @@ class XthingsCloudSwitch(XthingsCloudEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch."""
if self.device_data["type"] == "plug":
await self.coordinator.client.async_plug_on(self._device_id)
else:
await self.coordinator.client.async_switch_on(self._device_id)
await self.coordinator.client.async_plug_on(self._device_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch."""
if self.device_data["type"] == "plug":
await self.coordinator.client.async_plug_off(self._device_id)
else:
await self.coordinator.client.async_switch_off(self._device_id)
await self.coordinator.client.async_plug_off(self._device_id)
+128 -2
View File
@@ -4,12 +4,15 @@ from typing import Any, Unpack, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_ENTITY_ID,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -17,15 +20,23 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ANY,
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionCheckParams,
ConditionConfig,
EntityConditionBase,
)
from homeassistant.helpers.typing import ConfigType
from . import in_zone
from .const import DOMAIN
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
@@ -149,11 +160,126 @@ class ZoneCondition(Condition):
return all_ok
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(value_source=ATTR_IN_ZONES),
"device_tracker": DomainSpec(value_source=ATTR_IN_ZONES),
}
_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
},
}
)
class _ZoneTargetConditionBase(EntityConditionBase):
"""Base for zone-target conditions on person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the condition."""
super().__init__(hass, config)
assert config.options is not None
self._zone: str = config.options[CONF_ZONE]
def _in_target_zone(self, entity_state: State) -> bool:
"""Check if the entity is currently in the selected zone."""
in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or ()
return self._zone in in_zones
class InZoneCondition(_ZoneTargetConditionBase):
"""Condition: targeted entity is in the selected zone."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the entity is in the selected zone."""
return self._in_target_zone(entity_state)
class NotInZoneCondition(_ZoneTargetConditionBase):
"""Condition: targeted entity is not in the selected zone."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the entity is not in the selected zone."""
return not self._in_target_zone(entity_state)
_OCCUPANCY_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class _ZoneOccupancyConditionBase(EntityConditionBase):
"""Base for zone occupancy conditions (single zone, no behavior)."""
_domain_specs = {"zone": DomainSpec()}
_schema = _OCCUPANCY_CONDITION_SCHEMA
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and synthesize a target from the zone option.
We synthesize a target because we allow users to pick a single zone
to monitor, not a target.
"""
config = cast(ConfigType, cls._schema(config))
zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE]
config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]}
# `behavior` is needed by `EntityConditionBase.__init__`
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
return config
@staticmethod
def _occupancy_count(entity_state: State) -> int | None:
"""Return the zone's persons-in-zone count; None if unparsable."""
try:
return int(entity_state.state)
except TypeError, ValueError:
return None
@classmethod
def _is_occupied(cls, entity_state: State) -> bool:
"""Return True if the zone has at least one occupant."""
count = cls._occupancy_count(entity_state)
return count is not None and count >= 1
class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase):
"""Condition: the selected zone is occupied."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(entity_state)
class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase):
"""Condition: the selected zone is empty."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(entity_state) == 0
CONDITIONS: dict[str, type[Condition]] = {
"_": ZoneCondition,
"in_zone": InZoneCondition,
"not_in_zone": NotInZoneCondition,
"occupancy_is_detected": OccupancyIsDetectedCondition,
"occupancy_is_not_detected": OccupancyIsNotDetectedCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
"""Return the zone conditions."""
return CONDITIONS
@@ -0,0 +1,42 @@
.condition_zone: &condition_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
in_zone: *condition_zone
not_in_zone: *condition_zone
.condition_occupancy: &condition_occupancy
fields:
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
occupancy_is_detected: *condition_occupancy
occupancy_is_not_detected: *condition_occupancy
+20
View File
@@ -1,4 +1,18 @@
{
"conditions": {
"in_zone": {
"condition": "mdi:map-marker-check"
},
"not_in_zone": {
"condition": "mdi:map-marker-remove"
},
"occupancy_is_detected": {
"condition": "mdi:account-group"
},
"occupancy_is_not_detected": {
"condition": "mdi:account-off"
}
},
"services": {
"reload": {
"service": "mdi:reload"
@@ -10,6 +24,12 @@
},
"left": {
"trigger": "mdi:map-marker-minus"
},
"occupancy_cleared": {
"trigger": "mdi:account-off"
},
"occupancy_detected": {
"trigger": "mdi:account-group"
}
}
}
@@ -1,10 +1,74 @@
{
"common": {
"condition_behavior_name": "Check when",
"condition_for_name": "For at least",
"condition_zone_description": "The zone to test against.",
"condition_zone_name": "Zone",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_zone_description": "The zone to trigger on.",
"trigger_zone_name": "Zone"
},
"conditions": {
"in_zone": {
"description": "Tests if one or more persons or device trackers are in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::condition_zone_description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Is in zone"
},
"not_in_zone": {
"description": "Tests if one or more persons or device trackers are not in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::condition_zone_description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Is not in zone"
},
"occupancy_is_detected": {
"description": "Tests if a zone is occupied.",
"fields": {
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "The zone to monitor.",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Zone occupancy is detected"
},
"occupancy_is_not_detected": {
"description": "Tests if a zone is empty.",
"fields": {
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Zone occupancy is not detected"
}
},
"services": {
"reload": {
"description": "Reloads zones from the YAML-configuration.",
@@ -43,6 +107,32 @@
}
},
"name": "Left zone"
},
"occupancy_cleared": {
"description": "Triggers when a zone transitions from occupied to unoccupied.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]",
"name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]"
}
},
"name": "Zone occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers when a zone transitions to an occupied state.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "The zone to monitor.",
"name": "Zone"
}
},
"name": "Zone occupancy detected"
}
}
}
+74 -1
View File
@@ -10,7 +10,9 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
)
from homeassistant.core import (
@@ -41,6 +43,7 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from . import condition
from .const import DOMAIN
EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
@@ -66,7 +69,7 @@ _LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
},
}
)
@@ -203,10 +206,80 @@ class LeftZoneTrigger(ZoneTriggerBase):
return not self._in_target_zone(state)
_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class _ZoneOccupancyTriggerBase(EntityTriggerBase):
"""Base for zone occupancy triggers (single zone, no behavior)."""
_domain_specs = {"zone": DomainSpec()}
_schema = _OCCUPANCY_TRIGGER_SCHEMA
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and synthesize a target from the zone option.
We synthesize a target because we allow users to pick a single zone
to monitor, not a target.
"""
config = cast(ConfigType, cls._schema(config))
config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]}
return config
@staticmethod
def _occupancy_count(state: State) -> int | None:
"""Return the zone's persons-in-zone count; None if unparsable."""
try:
return int(state.state)
except TypeError, ValueError:
return None
@classmethod
def _is_occupied(cls, state: State) -> bool:
"""Return True if the zone has at least one occupant."""
count = cls._occupancy_count(state)
return count is not None and count >= 1
class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions to an occupied state."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(state)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously not occupied."""
return not self._is_occupied(from_state)
class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions from occupied to unoccupied."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(state) == 0
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously occupied."""
return self._is_occupied(from_state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
"occupancy_detected": OccupancyDetectedTrigger,
"occupancy_cleared": OccupancyClearedTrigger,
}
@@ -24,3 +24,19 @@
entered: *trigger_zone
left: *trigger_zone
.trigger_occupancy: &trigger_occupancy
fields:
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
occupancy_detected: *trigger_occupancy
occupancy_cleared: *trigger_occupancy
+17 -1
View File
@@ -1,9 +1,16 @@
"""Constants used by Home Assistant components."""
from enum import StrEnum
from functools import partial
from typing import TYPE_CHECKING, Final
from .generated.entity_platforms import EntityPlatforms
from .helpers.deprecation import (
DeprecatedConstant,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from .util.event_type import EventType
from .util.hass_dict import HassKey
from .util.signal_type import SignalType
@@ -758,7 +765,9 @@ CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³"
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³"
CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³"
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
"p/m³", "p/m³", "2027.7"
)
CONCENTRATION_PARTS_PER_MILLION: Final = "ppm"
CONCENTRATION_PARTS_PER_BILLION: Final = "ppb"
@@ -992,3 +1001,10 @@ FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}"
# This is not a hard limit, but caches and other
# data structures will be pre-allocated to this size
MAX_EXPECTED_ENTITY_IDS: Final = 16384
# These can be removed if no deprecated constants are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())
+2
View File
@@ -207,6 +207,7 @@ FLOWS = {
"enigma2",
"enocean",
"enphase_envoy",
"envertech_evt800",
"environment_canada",
"epic_games_store",
"epion",
@@ -347,6 +348,7 @@ FLOWS = {
"imeon_inverter",
"imgw_pib",
"immich",
"imou",
"improv_ble",
"incomfort",
"indevolt",
+4
View File
@@ -1415,6 +1415,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "vicare",
"macaddress": "B87424*",
},
{
"domain": "vistapool",
"hostname": "sugarwifi",
},
{
"domain": "withings",
"macaddress": "0024E4*",
+13 -1
View File
@@ -576,7 +576,7 @@
"name": "Ambient Radio Weather Network",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
"iot_class": "local_push"
},
"aseko_pool_live": {
"name": "Aseko Pool Live",
@@ -1861,6 +1861,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"envertech_evt800": {
"name": "ENVERTECH EVT800",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"environment_canada": {
"name": "Environment Canada",
"integration_type": "service",
@@ -3223,6 +3229,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"imou": {
"name": "Imou",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"improv_ble": {
"name": "Improv via BLE",
"integration_type": "device",
@@ -1953,6 +1953,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema(
[
{
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_NOTE): str, # Is only used in frontend
vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA,
vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA,
}

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