mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53211759cb | |||
| 4593059db2 | |||
| 593ae9eb80 | |||
| 37b4bcaa39 | |||
| 6bda3ea3a5 | |||
| f4db5fb346 | |||
| f04b0ee2c6 | |||
| 52c3e17de9 | |||
| 96c286f2e0 | |||
| 3e356de4e1 | |||
| 90a874d81b | |||
| 165024c6c9 | |||
| 66e4db3c0e | |||
| 0e6128c657 | |||
| 16febb36ba | |||
| dd7bd0c8a4 | |||
| c462a1c188 | |||
| 96c5110b7e | |||
| 64e8ed2737 | |||
| 4171d092f7 | |||
| 7af867ad4d | |||
| 907fe40304 | |||
| 261914c592 | |||
| 09637c1a3a | |||
| 0816385185 | |||
| 4b64b26870 | |||
| b20f9ad40a | |||
| 99d279bdd8 | |||
| 69e0e11077 | |||
| 9e3c143bd0 | |||
| 45c55543e9 | |||
| fb02e93a0c | |||
| a54b97eeca | |||
| 61c196405b | |||
| 9a047ad115 | |||
| 07a584057c | |||
| 5873dff1d9 | |||
| 30a2bd9b92 | |||
| 1065dce882 | |||
| 878a39194a | |||
| 2e2f4a7dcb | |||
| 46627984f8 | |||
| 5445f9e42b | |||
| 8ce2a5257d | |||
| 787828d7de | |||
| 9e96912a1e | |||
| fd578cfd4c | |||
| 94de8646c6 | |||
| 2d19e84d15 | |||
| a17cfbc2a5 | |||
| c552b0a067 | |||
| 80241a44d9 | |||
| d8b02ea6d6 | |||
| 36d2e85351 | |||
| 174ac9eafe | |||
| 772c426d5d | |||
| a32d028e3d | |||
| bc66c2610e | |||
| c22823ff8d |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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: >
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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%]"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+4
@@ -1415,6 +1415,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "vicare",
|
||||
"macaddress": "B87424*",
|
||||
},
|
||||
{
|
||||
"domain": "vistapool",
|
||||
"hostname": "sugarwifi",
|
||||
},
|
||||
{
|
||||
"domain": "withings",
|
||||
"macaddress": "0024E4*",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user