mirror of
https://github.com/home-assistant/core.git
synced 2026-06-04 02:13:42 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9430bb2e32 | |||
| 1283420fc6 | |||
| 88e85e4325 | |||
| 7cb08fdabc | |||
| 0cde867f93 | |||
| 149daf4f97 | |||
| 6ffc32159b | |||
| 1c2d1013e6 | |||
| af4eaed5ed | |||
| dba09c334a | |||
| c329bb4000 | |||
| 2e041dd45f | |||
| 8ba3e6c8c1 | |||
| 8db064c929 | |||
| 8124544125 | |||
| 6291179292 | |||
| 6f880ac8a9 | |||
| 7babb2423b | |||
| 7e1874ae96 | |||
| 1f50582a16 | |||
| 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 |
@@ -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
+8
-2
@@ -453,6 +453,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
|
||||
/homeassistant/components/ecowitt/ @pvizeli
|
||||
/tests/components/ecowitt/ @pvizeli
|
||||
/homeassistant/components/edifier_infrared/ @abmantis
|
||||
/tests/components/edifier_infrared/ @abmantis
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
@@ -501,6 +503,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
|
||||
@@ -623,8 +627,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -838,6 +842,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: {
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.6"]
|
||||
"requirements": ["actron-neo-api==0.5.12"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["airos==0.6.5"]
|
||||
"requirements": ["airos==0.6.8"]
|
||||
}
|
||||
|
||||
@@ -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,18 @@
|
||||
"""Edifier infrared integration for Home Assistant."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Edifier IR from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an Edifier IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Config flow for Edifier infrared integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
DOMAIN as INFRARED_DOMAIN,
|
||||
async_get_emitters,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
|
||||
|
||||
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for Edifier IR."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step - select IR entity and speaker model."""
|
||||
emitter_entity_ids = async_get_emitters(self.hass)
|
||||
if not emitter_entity_ids:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
if user_input is not None:
|
||||
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
|
||||
model = EdifierModel(user_input[CONF_MODEL])
|
||||
command_set = MODEL_TO_COMMAND_SET[model]
|
||||
|
||||
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
entity_name = infrared_entity_id
|
||||
if state := self.hass.states.get(infrared_entity_id):
|
||||
entity_name = state.name or infrared_entity_id
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Edifier {model.value} via {entity_name}",
|
||||
data={
|
||||
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
|
||||
CONF_MODEL: model.value,
|
||||
CONF_COMMAND_SET: command_set.value,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_MODEL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[model.value for model in EdifierModel],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Constants for the Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
|
||||
DOMAIN = "edifier_infrared"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
CONF_COMMAND_SET = "command_set"
|
||||
|
||||
type EdifierCode = (
|
||||
EdifierR1700BTCode
|
||||
| EdifierR1280DBCode
|
||||
| EdifierR1280TCode
|
||||
| EdifierS360DBCode
|
||||
| EdifierRC20GCode
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Common entity for Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.models import EdifierModel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class EdifierIrEntity(Entity):
|
||||
"""Edifier IR base entity providing common device info."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
|
||||
) -> None:
|
||||
"""Initialize Edifier IR entity."""
|
||||
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=f"Edifier {model.value}",
|
||||
manufacturer="Edifier",
|
||||
model=model.value,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "edifier_infrared",
|
||||
"name": "Edifier Infrared",
|
||||
"codeowners": ["@abmantis"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Media player platform for Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
|
||||
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
|
||||
from .entity import EdifierIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
COMMAND_SET_COMMANDS: dict[
|
||||
EdifierCommandSet,
|
||||
dict[
|
||||
MediaPlayerEntityFeature,
|
||||
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
|
||||
],
|
||||
] = {
|
||||
EdifierCommandSet.R1700BT: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1700BTCode.VOLUME_UP,),
|
||||
(EdifierR1700BTCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
|
||||
},
|
||||
EdifierCommandSet.R1280DB: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1280DBCode.VOLUME_UP,),
|
||||
(EdifierR1280DBCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
|
||||
},
|
||||
EdifierCommandSet.R1280T: {
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1280TCode.VOLUME_UP,),
|
||||
(EdifierR1280TCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
|
||||
},
|
||||
EdifierCommandSet.S360DB: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierS360DBCode.VOLUME_UP,),
|
||||
(EdifierS360DBCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
|
||||
},
|
||||
EdifierCommandSet.RC20G: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
|
||||
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Edifier IR media player."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
|
||||
model = EdifierModel(entry.data[CONF_MODEL])
|
||||
async_add_entities(
|
||||
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
|
||||
)
|
||||
|
||||
|
||||
class EdifierIrMediaPlayer(
|
||||
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
|
||||
):
|
||||
"""Edifier IR media player entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_assumed_state = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
model: EdifierModel,
|
||||
infrared_entity_id: str,
|
||||
command_set: EdifierCommandSet,
|
||||
) -> None:
|
||||
"""Initialize Edifier IR media player."""
|
||||
super().__init__(entry, model, unique_id_suffix="media_player")
|
||||
self._infrared_emitter_entity_id = infrared_entity_id
|
||||
self._commands = COMMAND_SET_COMMANDS[command_set]
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
self._attr_supported_features = MediaPlayerEntityFeature(0)
|
||||
for feature in self._commands:
|
||||
self._attr_supported_features |= feature
|
||||
|
||||
async def _send_codes(self, *codes: EdifierCode) -> None:
|
||||
"""Send one or more IR commands."""
|
||||
for code in codes:
|
||||
await self._send_command(code.to_command())
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the speaker."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the speaker."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
|
||||
@@ -0,0 +1,114 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not store runtime data.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only proxies commands through an existing infrared
|
||||
entity, so there is no separate connection to validate during setup.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require authentication.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Discovery is not supported for infrared integrations.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not fetch data from devices.
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry creates a single device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The media player entity is the primary entity and does not need a category.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The media player entity is the primary entity and should be enabled by default.
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not raise exceptions.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry manages exactly one device.
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration depends on infrared_protocols which provides only code
|
||||
definitions with no I/O, so async dependency does not apply.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Edifier device has already been configured with this transmitter.",
|
||||
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"infrared_entity_id": "IR transmitter",
|
||||
"model": "Speaker model"
|
||||
},
|
||||
"data_description": {
|
||||
"infrared_entity_id": "Select the infrared transmitter entity to use.",
|
||||
"model": "Choose your Edifier speaker model from the list."
|
||||
},
|
||||
"description": "Configure your Edifier speaker for IR control.",
|
||||
"title": "Set up Edifier IR speaker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,6 @@ class BatterySourceType(TypedDict):
|
||||
# statistic_id of a sensor (unit %) reporting the battery state of charge
|
||||
stat_soc: NotRequired[str]
|
||||
|
||||
# usable capacity in kWh, used to weight the combined state of charge
|
||||
capacity: NotRequired[float]
|
||||
|
||||
# An optional custom name for display in energy graphs
|
||||
name: NotRequired[str]
|
||||
|
||||
@@ -509,9 +506,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
vol.Optional("stat_soc"): str,
|
||||
vol.Optional("capacity"): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=0, min_included=False)
|
||||
),
|
||||
vol.Optional("name"): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"domain": "gentex_homelink",
|
||||
"name": "HomeLink",
|
||||
"codeowners": ["@niaexa", "@ryanjones-gentex"],
|
||||
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homelink-integration-api==0.0.1"]
|
||||
"requirements": ["homelink-integration-api==0.0.5"]
|
||||
}
|
||||
|
||||
@@ -238,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
|
||||
|
||||
@@ -266,15 +275,23 @@ def get_device_list_v1(
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
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 == 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 = [
|
||||
@@ -348,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(
|
||||
|
||||
@@ -115,7 +115,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
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":
|
||||
@@ -181,11 +188,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
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"]
|
||||
@@ -214,10 +226,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
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
|
||||
@@ -242,10 +259,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.36.0"],
|
||||
"requirements": ["aiohomeconnect==0.36.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The homee cover platform."""
|
||||
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -24,12 +25,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
OPEN_CLOSE_ATTRIBUTES = [
|
||||
AttributeType.OPEN_CLOSE,
|
||||
AttributeType.SLAT_ROTATION_IMPULSE,
|
||||
AttributeType.UP_DOWN,
|
||||
]
|
||||
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
||||
COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
@@ -43,6 +38,23 @@ IS_CLOSED_ATTRIBUTES = [
|
||||
]
|
||||
|
||||
|
||||
class HomeeCoverState(float, Enum):
|
||||
"""Open/closed states for covers in homee."""
|
||||
|
||||
OPEN = 0.0
|
||||
CLOSED = 1.0
|
||||
STOPPED = 2.0
|
||||
OPENING = 3.0
|
||||
CLOSING = 4.0
|
||||
|
||||
|
||||
class HomeeSlatState(float, Enum):
|
||||
"""Slat states for covers in homee."""
|
||||
|
||||
CLOSED = 1.0
|
||||
OPEN = 2.0
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||
"""Return the attribute used for opening/closing the cover."""
|
||||
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
|
||||
@@ -187,9 +199,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Return the opening status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 3
|
||||
self._open_close_attribute.get_value() == HomeeCoverState.OPENING
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 4
|
||||
else self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -199,9 +211,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Return the closing status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 4
|
||||
self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 3
|
||||
else self._open_close_attribute.get_value() == HomeeCoverState.OPENING
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -216,9 +228,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
|
||||
if self._open_close_attribute is not None:
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
return self._open_close_attribute.get_value() == 1
|
||||
return self._open_close_attribute.get_value() == HomeeCoverState.CLOSED
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
return self._open_close_attribute.get_value() == HomeeCoverState.OPEN
|
||||
|
||||
# If none of the above is present, it will be a slat only cover.
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
@@ -235,17 +247,25 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Open the cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.OPEN
|
||||
)
|
||||
else:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.CLOSED
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.CLOSED
|
||||
)
|
||||
else:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.OPEN
|
||||
)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -265,7 +285,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 2)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.STOPPED
|
||||
)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
@@ -275,9 +297,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
|
||||
else:
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
@@ -287,9 +309,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
|
||||
else:
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.5"]
|
||||
"requirements": ["aioautomower==2.7.6"]
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||
from incomfortclient import Gateway as InComfortGateway
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
InComfortConfigEntry,
|
||||
InComfortData,
|
||||
InComfortDataCoordinator,
|
||||
async_connect_gateway,
|
||||
)
|
||||
from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound
|
||||
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
|
||||
|
||||
PLATFORMS = (
|
||||
Platform.WATER_HEATER,
|
||||
@@ -27,75 +18,16 @@ PLATFORMS = (
|
||||
INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
|
||||
|
||||
|
||||
@callback
|
||||
def async_cleanup_stale_devices(
|
||||
hass: HomeAssistant,
|
||||
entry: InComfortConfigEntry,
|
||||
data: InComfortData,
|
||||
gateway_device: dr.DeviceEntry,
|
||||
) -> None:
|
||||
"""Cleanup stale heater devices and climates."""
|
||||
heater_serial_numbers = {heater.serial_no for heater in data.heaters}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
stale_heater_serial_numbers: list[str] = [
|
||||
device_entry.serial_number
|
||||
for device_entry in device_entries
|
||||
if device_entry.id != gateway_device.id
|
||||
and device_entry.serial_number is not None
|
||||
and device_entry.serial_number not in heater_serial_numbers
|
||||
]
|
||||
if not stale_heater_serial_numbers:
|
||||
return
|
||||
cleanup_devices: list[str] = []
|
||||
# Find stale heater and climate devices
|
||||
for serial_number in stale_heater_serial_numbers:
|
||||
cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)]
|
||||
cleanup_list.append(serial_number)
|
||||
cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list]
|
||||
cleanup_devices.extend(
|
||||
device_entry.id
|
||||
for device_entry in device_entries
|
||||
if device_entry.identifiers in cleanup_identifiers
|
||||
)
|
||||
for device_id in cleanup_devices:
|
||||
device_registry.async_remove_device(device_id)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
try:
|
||||
data = await async_connect_gateway(hass, dict(entry.data))
|
||||
for heater in data.heaters:
|
||||
await heater.update()
|
||||
except InvalidHeaterList as exc:
|
||||
raise NoHeaters from exc
|
||||
except InvalidGateway as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_credentials"
|
||||
) from exc
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == 404:
|
||||
raise NotFound from exc
|
||||
raise InComfortUnknownError from exc
|
||||
except TimeoutError as exc:
|
||||
raise InComfortTimeout from exc
|
||||
|
||||
# Register discovered gateway device
|
||||
device_registry = dr.async_get(hass)
|
||||
gateway_device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
|
||||
if entry.unique_id is not None
|
||||
else set(),
|
||||
manufacturer="Intergas",
|
||||
name="RFGateway",
|
||||
credentials = dict(entry.data)
|
||||
hostname = credentials.pop(CONF_HOST)
|
||||
client = InComfortGateway(
|
||||
hostname, **credentials, session=async_get_clientsession(hass)
|
||||
)
|
||||
async_cleanup_stale_devices(hass, entry, data, gateway_device)
|
||||
coordinator = InComfortDataCoordinator(hass, entry, data)
|
||||
|
||||
coordinator = InComfortDataCoordinator(hass, entry, client)
|
||||
entry.runtime_data = coordinator
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||
from incomfortclient import (
|
||||
Gateway as InComfortGateway,
|
||||
InvalidGateway,
|
||||
InvalidHeaterList,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -17,6 +21,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
@@ -28,7 +33,7 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
|
||||
from .coordinator import InComfortConfigEntry, async_connect_gateway
|
||||
from .coordinator import InComfortConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
|
||||
@@ -81,7 +86,13 @@ async def async_try_connect_gateway(
|
||||
) -> dict[str, str] | None:
|
||||
"""Try to connect to the Lan2RF gateway."""
|
||||
try:
|
||||
await async_connect_gateway(hass, config)
|
||||
client = InComfortGateway(
|
||||
hostname=config[CONF_HOST],
|
||||
username=config.get(CONF_USERNAME),
|
||||
password=config.get(CONF_PASSWORD),
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
await client.heaters()
|
||||
except InvalidGateway:
|
||||
return {"base": "auth_error"}
|
||||
except InvalidHeaterList:
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any, override
|
||||
from typing import override
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from incomfortclient import (
|
||||
Gateway as InComfortGateway,
|
||||
Heater as InComfortHeater,
|
||||
InvalidGateway,
|
||||
InvalidHeaterList,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -36,20 +37,41 @@ class InComfortData:
|
||||
heaters: list[InComfortHeater] = field(default_factory=list)
|
||||
|
||||
|
||||
async def async_connect_gateway(
|
||||
@callback
|
||||
def async_cleanup_stale_devices(
|
||||
hass: HomeAssistant,
|
||||
entry_data: dict[str, Any],
|
||||
) -> InComfortData:
|
||||
"""Validate the configuration."""
|
||||
credentials = dict(entry_data)
|
||||
hostname = credentials.pop(CONF_HOST)
|
||||
|
||||
client = InComfortGateway(
|
||||
hostname, **credentials, session=async_get_clientsession(hass)
|
||||
entry: InComfortConfigEntry,
|
||||
data: InComfortData,
|
||||
gateway_device: dr.DeviceEntry,
|
||||
) -> None:
|
||||
"""Cleanup stale heater devices and climates."""
|
||||
heater_serial_numbers = {heater.serial_no for heater in data.heaters}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
heaters = await client.heaters()
|
||||
|
||||
return InComfortData(client=client, heaters=heaters)
|
||||
stale_heater_serial_numbers: list[str] = [
|
||||
device_entry.serial_number
|
||||
for device_entry in device_entries
|
||||
if device_entry.id != gateway_device.id
|
||||
and device_entry.serial_number is not None
|
||||
and device_entry.serial_number not in heater_serial_numbers
|
||||
]
|
||||
if not stale_heater_serial_numbers:
|
||||
return
|
||||
cleanup_devices: list[str] = []
|
||||
# Find stale heater and climate devices
|
||||
for serial_number in stale_heater_serial_numbers:
|
||||
cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)]
|
||||
cleanup_list.append(serial_number)
|
||||
cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list]
|
||||
cleanup_devices.extend(
|
||||
device_entry.id
|
||||
for device_entry in device_entries
|
||||
if device_entry.identifiers in cleanup_identifiers
|
||||
)
|
||||
for device_id in cleanup_devices:
|
||||
device_registry.async_remove_device(device_id)
|
||||
|
||||
|
||||
class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
@@ -61,10 +83,9 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: InComfortConfigEntry,
|
||||
incomfort_data: InComfortData,
|
||||
client: InComfortGateway,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self.unique_id = config_entry.unique_id
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
@@ -72,28 +93,65 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
name="InComfort datacoordinator",
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
self.incomfort_data = incomfort_data
|
||||
self.client = client
|
||||
self.unique_id = config_entry.unique_id
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> InComfortData:
|
||||
"""Fetch data from API endpoint."""
|
||||
"""Fetch data from Incomfort."""
|
||||
try:
|
||||
for heater in self.incomfort_data.heaters:
|
||||
heaters = await self.client.heaters()
|
||||
for heater in heaters:
|
||||
await heater.update()
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == 401:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_credentials"
|
||||
) from exc
|
||||
except InvalidGateway as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_with_error_message",
|
||||
translation_placeholders={"error": exc.message},
|
||||
translation_key="timeout_error",
|
||||
) from exc
|
||||
except ClientResponseError as exc:
|
||||
if exc.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from exc
|
||||
_LOGGER.exception("Error communicating with InComfort gateway")
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown",
|
||||
) from exc
|
||||
except InvalidHeaterList as exc:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_with_error_message",
|
||||
translation_placeholders={"error": exc.message},
|
||||
translation_key="no_heaters",
|
||||
) from exc
|
||||
return self.incomfort_data
|
||||
|
||||
incomfort_data = InComfortData(
|
||||
client=self.client,
|
||||
heaters=heaters,
|
||||
)
|
||||
|
||||
# Register discovered gateway device
|
||||
# Respect this as it is. Maybe later...
|
||||
device_registry = dr.async_get(self.hass)
|
||||
gateway_device = device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, self.config_entry.entry_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.config_entry.unique_id)}
|
||||
if self.config_entry.unique_id is not None
|
||||
else set(),
|
||||
manufacturer="Intergas",
|
||||
name="RFGateway",
|
||||
)
|
||||
async_cleanup_stale_devices(
|
||||
self.hass,
|
||||
self.config_entry,
|
||||
incomfort_data,
|
||||
gateway_device,
|
||||
)
|
||||
|
||||
return incomfort_data
|
||||
|
||||
@@ -27,15 +27,14 @@ def _async_get_diagnostics(
|
||||
redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG)
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
nr_heaters = len(coordinator.incomfort_data.heaters)
|
||||
nr_heaters = len(coordinator.data.heaters)
|
||||
status: dict[str, Any] = {
|
||||
f"heater_{n}": coordinator.incomfort_data.heaters[n].status
|
||||
for n in range(nr_heaters)
|
||||
f"heater_{n}": coordinator.data.heaters[n].status for n in range(nr_heaters)
|
||||
}
|
||||
for n in range(nr_heaters):
|
||||
status[f"heater_{n}"]["rooms"] = {
|
||||
m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status)
|
||||
for m in range(len(coordinator.incomfort_data.heaters[n].rooms))
|
||||
m: dict(coordinator.data.heaters[n].rooms[m].status)
|
||||
for m in range(len(coordinator.data.heaters[n].rooms))
|
||||
}
|
||||
return {
|
||||
"config": redacted_config,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Exceptions raised by Intergas InComfort integration."""
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class NotFound(HomeAssistantError):
|
||||
"""Raise exception if no Lan2RF Gateway was found."""
|
||||
|
||||
translation_domain = DOMAIN
|
||||
translation_key = "not_found"
|
||||
|
||||
|
||||
class NoHeaters(ConfigEntryNotReady):
|
||||
"""Raise exception if no heaters are found."""
|
||||
|
||||
translation_domain = DOMAIN
|
||||
translation_key = "no_heaters"
|
||||
|
||||
|
||||
class InComfortTimeout(ConfigEntryNotReady):
|
||||
"""Raise exception if no heaters are found."""
|
||||
|
||||
translation_domain = DOMAIN
|
||||
translation_key = "timeout_error"
|
||||
|
||||
|
||||
class InComfortUnknownError(ConfigEntryNotReady):
|
||||
"""Raise exception if no heaters are found."""
|
||||
|
||||
translation_domain = DOMAIN
|
||||
translation_key = "unknown"
|
||||
@@ -131,7 +131,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"incorrect_credentials": { "message": "Incorrect credentials." },
|
||||
"invalid_auth": {
|
||||
"message": "[%key:component::incomfort::config::error::auth_error%]"
|
||||
},
|
||||
"no_heaters": {
|
||||
"message": "[%key:component::incomfort::config::error::no_heaters%]"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==5.8.0"]
|
||||
"requirements": ["infrared-protocols==5.8.1"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/itach",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyitachip2ir==0.0.7"]
|
||||
"requirements": ["pyitachip2ir2==0.0.8"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.1.1",
|
||||
"onvif-zeep-async==4.2.0",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
|
||||
@@ -15,7 +15,11 @@ from opendisplay import (
|
||||
OpenDisplayError,
|
||||
)
|
||||
|
||||
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 Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -83,9 +87,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry)
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
if ble_device is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find OpenDisplay device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
encryption_key = _get_encryption_key(entry)
|
||||
|
||||
try:
|
||||
|
||||
@@ -22,7 +22,11 @@ from opendisplay import (
|
||||
from PIL import Image as PILImage, ImageOps
|
||||
import voluptuous as vol
|
||||
|
||||
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.components.http.auth import async_sign_path
|
||||
from homeassistant.components.media_source import async_resolve_media
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -108,7 +112,7 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry:
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={"address": mac_address},
|
||||
)
|
||||
|
||||
@@ -171,7 +175,14 @@ async def _async_upload_image(call: ServiceCall) -> None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"address": address},
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
call.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
current = asyncio.current_task()
|
||||
|
||||
@@ -74,8 +74,11 @@
|
||||
"authentication_error": {
|
||||
"message": "Authentication failed. Please update the encryption key."
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry not found: `{address}`"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Could not find Bluetooth device with address `{address}`."
|
||||
"message": "Could not find Bluetooth device with address `{address}`. Reason: {reason}"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Device `{device_id}` is not a valid OpenDisplay device."
|
||||
|
||||
@@ -8,9 +8,10 @@ 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]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Support for monitoring OpenEVSE Charger binary sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpenEVSEBinarySensorDescription(BinarySensorEntityDescription):
|
||||
"""Describes an OpenEVSE binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[OpenEVSE], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSOR_TYPES: tuple[OpenEVSEBinarySensorDescription, ...] = (
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="vehicle",
|
||||
translation_key="vehicle",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
value_fn=lambda ev: ev.vehicle,
|
||||
),
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="divert_active",
|
||||
translation_key="divert_active",
|
||||
value_fn=lambda ev: ev.divert_active,
|
||||
),
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="using_ethernet",
|
||||
translation_key="using_ethernet",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ev: ev.using_ethernet,
|
||||
),
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="shaper_active",
|
||||
translation_key="shaper_active",
|
||||
value_fn=lambda ev: ev.shaper_active,
|
||||
),
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="has_limit",
|
||||
translation_key="has_limit",
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ev: ev.has_limit,
|
||||
),
|
||||
OpenEVSEBinarySensorDescription(
|
||||
key="mqtt_connected",
|
||||
translation_key="mqtt_connected",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ev: ev.mqtt_connected,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenEVSEConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up OpenEVSE binary sensors based on config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
identifier = entry.unique_id or entry.entry_id
|
||||
async_add_entities(
|
||||
OpenEVSEBinarySensor(coordinator, description, identifier, entry.unique_id)
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
class OpenEVSEBinarySensor(
|
||||
CoordinatorEntity[OpenEVSEDataUpdateCoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Implementation of an OpenEVSE binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: OpenEVSEBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OpenEVSEDataUpdateCoordinator,
|
||||
description: OpenEVSEBinarySensorDescription,
|
||||
identifier: str,
|
||||
unique_id: str | None,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{identifier}-{description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
manufacturer="OpenEVSE",
|
||||
)
|
||||
if unique_id:
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_NETWORK_MAC, unique_id)
|
||||
}
|
||||
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.value_fn(self.coordinator.charger)
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,26 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"divert_active": {
|
||||
"name": "Divert active"
|
||||
},
|
||||
"has_limit": {
|
||||
"name": "Limit active"
|
||||
},
|
||||
"mqtt_connected": {
|
||||
"name": "MQTT connected"
|
||||
},
|
||||
"shaper_active": {
|
||||
"name": "Shaper active"
|
||||
},
|
||||
"using_ethernet": {
|
||||
"name": "Ethernet connected"
|
||||
},
|
||||
"vehicle": {
|
||||
"name": "Vehicle connected"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"charge_rate": {
|
||||
"name": "Charge rate"
|
||||
@@ -168,10 +188,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."
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Device tracker support for OPNsense routers."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -98,20 +96,3 @@ class OPNsenseDeviceTrackerEntity(
|
||||
hostname = device_data.get("hostname")
|
||||
return hostname or None
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
device_data = self.device_data
|
||||
if not device_data:
|
||||
return {}
|
||||
|
||||
attrs = {}
|
||||
if manufacturer := device_data.get("manufacturer"):
|
||||
attrs["manufacturer"] = manufacturer
|
||||
if interface := device_data.get("intf_description"):
|
||||
attrs["interface"] = interface
|
||||
if expires := device_data.get("expires"):
|
||||
attrs["expires"] = expires
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -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"])
|
||||
):
|
||||
|
||||
@@ -23,9 +23,9 @@ SPEED_LIST = [
|
||||
Speed.Turbo,
|
||||
]
|
||||
|
||||
PRESET_MODE_AUTO = "Auto"
|
||||
PRESET_MODE_MANUAL = "Manual"
|
||||
PRESET_MODE_POLLEN = "Pollen"
|
||||
PRESET_MODE_AUTO = "auto"
|
||||
PRESET_MODE_MANUAL = "manual"
|
||||
PRESET_MODE_POLLEN = "pollen"
|
||||
|
||||
PRESET_MODES = {
|
||||
PRESET_MODE_AUTO: Mode.Auto,
|
||||
@@ -46,6 +46,7 @@ async def async_setup_entry(
|
||||
class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
|
||||
"""Fan control functions of the Rabbit Air air purifier."""
|
||||
|
||||
_attr_translation_key = "rabbitair"
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.SET_SPEED
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"entity": {
|
||||
"fan": {
|
||||
"rabbitair": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "mdi:fan-auto",
|
||||
"manual": "mdi:fan",
|
||||
"pollen": "mdi:flower-pollen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"fan": {
|
||||
"rabbitair": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"pollen": "Pollen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"HTTP(S) port": api.port,
|
||||
"Baichuan port": api.baichuan.port,
|
||||
"Baichuan only": api.baichuan_only,
|
||||
"Baichuan connection": api.baichuan.connection_type.value,
|
||||
"WiFi connection": api.wifi_connection(),
|
||||
"WiFi signal": api.wifi_signal(),
|
||||
"RTMP enabled": api.rtmp_enabled,
|
||||
@@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics(
|
||||
"ONVIF enabled": api.onvif_enabled,
|
||||
"event connection": host.event_connection,
|
||||
"stream protocol": api.protocol,
|
||||
"is NVR": api.is_nvr,
|
||||
"is Hub": api.is_hub,
|
||||
"is Battery": api.is_battery,
|
||||
"channels": api.channels,
|
||||
"stream channels": api.stream_channels,
|
||||
"IPC cams": ipc_cam,
|
||||
"Chimes": chimes,
|
||||
"Broken cmds": api.broken_cmds,
|
||||
"Baichuan fallbacks": api.baichuan_cmds,
|
||||
"capabilities": api.capabilities,
|
||||
"cmd list": host.update_cmd,
|
||||
"firmware ch list": host.firmware_ch_list,
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.20.0"]
|
||||
"requirements": ["reolink-aio==0.20.1"]
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
},
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["verisure"],
|
||||
"requirements": ["vsure==2.6.7"]
|
||||
"requirements": ["vsure==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user